@synqa/cli 1.1.1 → 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/dist/hooks.js ADDED
@@ -0,0 +1,219 @@
1
+ // Hooks: shell commands that run at SessionStart, PreToolUse, PostToolUse and Stop.
2
+ //
3
+ // Two files, merged: .synqa/hooks.json in the project and hooks.json in the user config folder.
4
+ // { "hooks": { "PreToolUse": [ { "matcher": "write_file|edit_file", "command": "python check.py" } ],
5
+ // "Stop": [ "echo done" ] } }
6
+ // A hook gets a JSON description of the event on stdin. A PreToolUse hook blocks the tool by
7
+ // exiting with code 2 or by printing {"decision":"block","reason":"..."}; any other output, JSON
8
+ // or not, is ignored without a message. A hook that fails to start, fails or times out never
9
+ // stops the session and never prints an error at start-up.
10
+ //
11
+ // A project file is repository content, so project hooks start switched OFF until the person
12
+ // turns them on with /hooks; user hooks start on.
13
+ import { spawn } from "node:child_process";
14
+ import { createHash } from "node:crypto";
15
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
16
+ import { delimiter, join, resolve } from "node:path";
17
+ import { configDir, readConfig, writeConfig } from "./config.js";
18
+ import { sanitize } from "./sanitize.js";
19
+
20
+ export const HOOK_EVENTS = ["SessionStart", "PreToolUse", "PostToolUse", "Stop"] ;
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+ const DEFAULT_TIMEOUT_MS = 10_000;
36
+ const MAX_TIMEOUT_MS = 60_000;
37
+ const MAX_OUTPUT = 64 * 1024;
38
+
39
+ export const projectHooksPath = (cwd ) => join(cwd, ".synqa", "hooks.json");
40
+ export const userHooksPath = (env = process.env) => join(configDir(env), "hooks.json");
41
+
42
+ function readJson(path ) {
43
+ try {
44
+ return JSON.parse(readFileSync(path, "utf8"));
45
+ } catch {
46
+ return undefined; // missing or malformed: no hooks, no noise
47
+ }
48
+ }
49
+
50
+ // The project root a project hook is approved for: the real path, else the resolved one.
51
+ function canonicalRoot(cwd ) {
52
+ const abs = resolve(cwd);
53
+ try { return realpathSync(abs); } catch { return abs; }
54
+ }
55
+
56
+ function parseFile(raw , scope , state , projectRoot = "") {
57
+ if (!raw || typeof raw !== "object") return [];
58
+ const root = raw ;
59
+ const table = (root.hooks && typeof root.hooks === "object" ? root.hooks : root) ;
60
+ const out = [];
61
+ for (const event of HOOK_EVENTS) {
62
+ const list = table[event];
63
+ if (!Array.isArray(list)) continue;
64
+ list.forEach((entry, index) => {
65
+ const e = typeof entry === "string" ? { command: entry } : (entry );
66
+ if (!e || typeof e.command !== "string" || e.command.trim() === "") return;
67
+ const command = e.command.trim();
68
+ const t = typeof e.timeout === "number" && e.timeout > 0 ? Math.min(e.timeout, MAX_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
69
+ const matcher = typeof e.matcher === "string" && e.matcher !== "" ? e.matcher : undefined;
70
+ // A project hook id binds the approval to the project root and the exact entry (command,
71
+ // matcher, timeout): another project or any edit gives a new id, which is off until approved.
72
+ const id = scope === "project"
73
+ ? `project:${event}:${index}:${createHash("sha256").update(JSON.stringify([projectRoot, command, matcher ?? null, t])).digest("hex")}`
74
+ : `${scope}:${event}:${index}:${createHash("sha1").update(command).digest("hex").slice(0, 6)}`;
75
+ out.push({
76
+ id, scope, event, command, timeoutMs: t,
77
+ matcher,
78
+ enabled: state[id] ?? scope === "user",
79
+ });
80
+ });
81
+ }
82
+ return out;
83
+ }
84
+
85
+ export function loadHooks(cwd , env = process.env) {
86
+ const state = readConfig(env).hooksState ?? {};
87
+ return [
88
+ ...parseFile(readJson(userHooksPath(env)), "user", state),
89
+ ...parseFile(readJson(projectHooksPath(cwd)), "project", state, canonicalRoot(cwd)),
90
+ ];
91
+ }
92
+
93
+ // Flips one hook on or off and saves the choice. Returns the new state, or undefined for an unknown id.
94
+ export function toggleHook(cwd , id , env = process.env) {
95
+ const hook = loadHooks(cwd, env).find((h) => h.id === id);
96
+ if (!hook) return undefined;
97
+ const cfg = readConfig(env);
98
+ const enabled = !hook.enabled;
99
+ writeConfig({ ...cfg, hooksState: { ...cfg.hooksState, [id]: enabled } }, env);
100
+ return enabled;
101
+ }
102
+
103
+ export function describeHook(h ) {
104
+ const cmd = sanitize(h.command).slice(0, 60);
105
+ return `${h.event}${h.matcher ? ` [${sanitize(h.matcher).slice(0, 30)}]` : ""} ${cmd} (${h.scope}, ${h.enabled ? "on" : "off"})`;
106
+ }
107
+
108
+ // ---------------------------------------------------------------- python fallback
109
+
110
+ // Is `name` an executable on PATH? Windows also tries the PATHEXT suffixes.
111
+ export function onPath(name , env = process.env, platform = process.platform) {
112
+ const path = env.PATH ?? env.Path ?? "";
113
+ const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter(Boolean)] : [""];
114
+ for (const dir of path.split(platform === "win32" ? ";" : delimiter).filter(Boolean)) {
115
+ for (const ext of exts) {
116
+ const p = join(dir, name + ext);
117
+ try {
118
+ if (existsSync(p) && statSync(p).isFile()) return true;
119
+ } catch {
120
+ // unreadable entry: keep looking
121
+ }
122
+ }
123
+ }
124
+ return false;
125
+ }
126
+
127
+ // A command that starts with `python` runs with `python3` (or `py` on Windows) when plain python
128
+ // is not on PATH. Only the first word is rewritten.
129
+ export function resolveHookCommand(
130
+ command , platform = process.platform, has = (n) => onPath(n, process.env, platform),
131
+ ) {
132
+ const m = /^(\s*)(python(?:\.exe)?)(?=\s|$)/i.exec(command);
133
+ if (!m || has("python")) return command;
134
+ const candidates = platform === "win32" ? ["python3", "py"] : ["python3"];
135
+ const found = candidates.find((c) => has(c));
136
+ return found ? (m[1] ?? "") + found + command.slice(m[0].length) : command;
137
+ }
138
+
139
+ // ---------------------------------------------------------------- running
140
+
141
+
142
+
143
+
144
+
145
+
146
+
147
+
148
+
149
+
150
+ // Reads {"decision":"block"} / {"block":true} out of free-form output; anything else is not a decision.
151
+ export function parseDecision(output ) {
152
+ const text = output.trim();
153
+ if (!text.startsWith("{")) return { blocked: false };
154
+ try {
155
+ const j = JSON.parse(text) ;
156
+ const d = typeof j.decision === "string" ? j.decision.toLowerCase() : "";
157
+ if (d === "block" || d === "deny" || j.block === true) {
158
+ return { blocked: true, reason: typeof j.reason === "string" ? sanitize(j.reason).slice(0, 300) : undefined };
159
+ }
160
+ } catch {
161
+ // not JSON after all: ignored
162
+ }
163
+ return { blocked: false };
164
+ }
165
+
166
+ function hookEnv(env , platform ) {
167
+ return platform === "win32" ? { ...env, NoDefaultCurrentDirectoryInExePath: "1" } : env;
168
+ }
169
+
170
+ export function runHook(hook , payload , cwd , d = {}) {
171
+ const platform = d.platform ?? process.platform;
172
+ const command = resolveHookCommand(hook.command, platform, d.has);
173
+ const done = (r ) => ({ hook, code: null, output: "", blocked: false, ...r });
174
+ return new Promise((resolve) => {
175
+ let out = "";
176
+ let settled = false;
177
+ const finish = (r ) => { if (!settled) { settled = true; clearTimeout(timer); resolve(r); } };
178
+ let child;
179
+ try {
180
+ child = (d.spawnFn ?? spawn)(command, { cwd, shell: true, env: hookEnv(d.env ?? process.env, platform), stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
181
+ } catch (err) {
182
+ resolve(done({ error: sanitize(err instanceof Error ? err.message : String(err)) }));
183
+ return;
184
+ }
185
+ const timer = setTimeout(() => { try { child.kill(); } catch { /* already gone */ } finish(done({ error: "timed out", output: out })); }, hook.timeoutMs);
186
+ (timer ).unref?.();
187
+ child.stdout?.on("data", (c ) => { if (out.length < MAX_OUTPUT) out += c.toString("utf8"); });
188
+ child.on("error", (err ) => finish(done({ error: sanitize(err.message) })));
189
+ child.on("close", (code ) => {
190
+ const dec = parseDecision(out);
191
+ const exit2 = code === 2;
192
+ finish(done({ code, output: out, blocked: hook.event === "PreToolUse" && (exit2 || dec.blocked), reason: dec.reason }));
193
+ });
194
+ child.stdin?.on("error", () => {});
195
+ try {
196
+ child.stdin?.end(JSON.stringify(payload) + "\n");
197
+ } catch {
198
+ // a hook that does not read stdin is fine
199
+ }
200
+ });
201
+ }
202
+
203
+ // Runs every enabled hook of `event` (matching the tool name for the tool events), one after the
204
+ // other. Never throws.
205
+ export async function runHooks(hooks , event , payload , cwd , d = {}) {
206
+ const out = [];
207
+ for (const h of hooks) {
208
+ if (!h.enabled || h.event !== event) continue;
209
+ if (h.matcher && payload.tool !== undefined) {
210
+ try {
211
+ if (!new RegExp(`^(?:${h.matcher})$`).test(payload.tool)) continue;
212
+ } catch {
213
+ continue; // a bad pattern matches nothing
214
+ }
215
+ }
216
+ out.push(await runHook(h, { event, ...payload }, cwd, d));
217
+ }
218
+ return out;
219
+ }
package/dist/main.js CHANGED
@@ -3,12 +3,20 @@ import { runAgent } from "./agent.js";
3
3
  import { ApiError, checkKey } from "./api.js";
4
4
  import { announceNotes, freshHistory, runCommand, statusParts } from "./commands.js";
5
5
 
6
- import { looksLikeKey, readConfig, resolveApiKey, writeConfig, configPath } from "./config.js";
6
+ import { API_BASE, looksLikeKey, readConfig, resolveApiKey, resolveProviderKey, writeConfig, configPath } from "./config.js";
7
7
  import { EditJournal } from "./journal.js";
8
8
  import { isHomeDir, isSensitivePath, unsafeStartDir } from "./paths.js";
9
9
 
10
10
 
11
11
  import { join } from "node:path";
12
+ import { loadHooks, runHooks, toggleHook } from "./hooks.js";
13
+
14
+ import { MODE_LABEL, nextMode } from "./modes.js";
15
+ import { providerDef, unlockGate } from "./providers.js";
16
+ import { pickOne, readMasked, streamKeys } from "./select.js";
17
+
18
+ import { bashEnv } from "./tools.js";
19
+ import { checkForUpdate, runUpdate } from "./update.js";
12
20
 
13
21
  // A workspace inside a protected area (~/.dotfiles, ~/.config/nvim, ~/Library/...): every file is
14
22
  // refused there, so say why once at start-up instead of leaving the user with refusals.
@@ -18,9 +26,10 @@ export function protectedWorkspaceWarning(cwd , home )
18
26
  }
19
27
 
20
28
  // The start-up notes both the session banner and a one-shot run print.
21
- export function startWarnings(env , cwd , home ) {
29
+ // envInUse: false when a saved key is used and SYNQA_API_KEY is ignored (no warning then).
30
+ export function startWarnings(env , cwd , home , envInUse = true) {
22
31
  const out = [];
23
- if (env.SYNQA_API_KEY?.trim()) out.push("using the key from SYNQA_API_KEY (not the one saved by synqa login)");
32
+ if (envInUse && env.SYNQA_API_KEY?.trim()) out.push("using the key from SYNQA_API_KEY (not the one saved by synqa login)");
24
33
  const inProtected = protectedWorkspaceWarning(cwd, home);
25
34
  if (inProtected) out.push(inProtected);
26
35
  return out;
@@ -111,54 +120,31 @@ const BALANCE_TIMEOUT_MS = 2000;
111
120
  const HELP = `Usage:
112
121
  synqa interactive session in the current folder
113
122
  synqa "do something" one-shot task
114
- synqa login save your API key
123
+ synqa login save your Synqa API key
115
124
  Options:
116
125
  -y, --yes do not ask before ordinary file writes and edits
117
126
  (shell commands always ask; not allowed in your home folder)
127
+ --env-key use SYNQA_API_KEY (and the other provider key variables)
128
+ instead of a key saved by /login
118
129
  -m, --model <tier> blink | sonata | aeon (default blink)
119
- -e, --effort <level> default | low | medium (default default)
130
+ -e, --effort <level> default | low | medium | high (default default)
120
131
  -h, --help -v, --version
121
- In a session, type / for the command menu (/model, /effort, /status, /init, /compact, /undo,
122
- /clear, /help, /exit).`;
132
+ In a session, type / for the command menu (/model, /effort, /provider, /login, /logout, /status,
133
+ /init, /compact, /undo, /hooks, /update, /clear, /help, /exit). Shift+Tab cycles the permission
134
+ mode (ask before edits, auto-accept edits, plan only).`;
123
135
 
124
- // Said when SYNQA_API_KEY is set: it wins over the key that synqa login saved.
125
- export const ENV_KEY_NOTE = "SYNQA_API_KEY is set in this shell, so it is used instead of the key saved by synqa login. Remove it (unset SYNQA_API_KEY, and delete the line from ~/.zshrc or ~/.bash_profile) to use the saved key.";
136
+ // Said when SYNQA_API_KEY is set in the shell but a key saved by synqa login or /login exists:
137
+ // the saved key wins (since 1.2.0), so there is nothing to unset.
138
+ export const ENV_KEY_NOTE = "SYNQA_API_KEY is set in this shell and takes effect only because the --env-key flag was passed or no key is saved. A key saved by synqa login or /login wins otherwise.";
126
139
 
127
- // Masked single-line input: nothing typed is echoed.
128
- export function readSecret(promptText ) {
129
- return new Promise((resolve, reject) => {
130
- const stdin = process.stdin;
131
- if (!stdin.isTTY) {
132
- reject(new Error("login needs an interactive terminal (or set SYNQA_API_KEY)"));
133
- return;
134
- }
135
- process.stdout.write(promptText);
136
- let value = "";
137
- stdin.setRawMode(true);
138
- stdin.resume();
139
- stdin.setEncoding("utf8");
140
- const onData = (chunk ) => {
141
- for (const ch of chunk) {
142
- if (ch === "\r" || ch === "\n") {
143
- stdin.setRawMode(false);
144
- stdin.pause();
145
- stdin.off("data", onData);
146
- process.stdout.write("\n");
147
- resolve(value.trim());
148
- return;
149
- }
150
- if (ch === "\u0003") {
151
- stdin.setRawMode(false);
152
- process.stdout.write("\n");
153
- reject(new Error("cancelled"));
154
- return;
155
- }
156
- if (ch === "\u007f" || ch === "\b") value = value.slice(0, -1);
157
- else if (ch >= " ") value += ch;
158
- }
159
- };
160
- stdin.on("data", onData);
161
- });
140
+ // Masked single-line input on the process terminal: each character shows as a bullet.
141
+ export async function readSecret(promptText ) {
142
+ const stdin = process.stdin;
143
+ if (!stdin.isTTY) throw new Error("login needs an interactive terminal (or set SYNQA_API_KEY)");
144
+ const v = await readMasked(promptText, { keys: streamKeys(stdin), out: process.stdout });
145
+ stdin.pause();
146
+ if (v === undefined) throw new Error("cancelled");
147
+ return v;
162
148
  }
163
149
 
164
150
  // The error text can come from the network, so it is sanitized before printing.
@@ -180,18 +166,17 @@ export async function login() {
180
166
  }
181
167
  const file = writeConfig({ ...readConfig(), apiKey: key });
182
168
  console.log(`Key verified and saved to ${file}`);
183
- const fromEnv = process.env.SYNQA_API_KEY?.trim();
184
- if (fromEnv && fromEnv !== key) console.error(`\nWarning: ${ENV_KEY_NOTE}`);
185
169
  return 0;
186
170
  }
187
171
 
188
-
172
+
189
173
 
190
174
  export function parseArgs(argv ) {
191
- const a = { allowHome: false, yes: false, model: "", effort: "", help: false, version: false, rest: [] };
175
+ const a = { allowHome: false, yes: false, envKey: false, model: "", effort: "", help: false, version: false, rest: [] };
192
176
  for (let i = 0; i < argv.length; i++) {
193
177
  const t = argv[i] ;
194
178
  if (t === "-y" || t === "--yes") a.yes = true;
179
+ else if (t === "--env-key") a.envKey = true;
195
180
  else if (t === "--allow-home") a.allowHome = true; // accepted and ignored since 1.1.1
196
181
  else if (t === "-h" || t === "--help") a.help = true;
197
182
  else if (t === "-v" || t === "--version") a.version = true;
@@ -202,6 +187,20 @@ export function parseArgs(argv ) {
202
187
  return a;
203
188
  }
204
189
 
190
+ // Where a session starts: the saved active provider if its credentials are still there, else Synqa Cloud.
191
+ export function startProvider(synqaKey , env , envKey ) {
192
+ const cfg = readConfig(env);
193
+ const id = cfg.activeProvider;
194
+ const def = id ? providerDef(id) : undefined;
195
+ if (id && def && id !== "synqa") {
196
+ const entry = cfg.providers?.[id];
197
+ const key = resolveProviderKey(id, env, { envKey });
198
+ const base = id === "custom" ? entry?.baseUrl : def.baseUrl;
199
+ if (key && base) return { apiKey: key, providerId: def.id, baseUrl: base, modelId: entry?.model };
200
+ }
201
+ return { apiKey: synqaKey, providerId: "synqa", baseUrl: API_BASE };
202
+ }
203
+
205
204
  // `input` is the key source; tests pass a stream that ends at once, like `< /dev/null`.
206
205
  export async function main(argv , input = process.stdin) {
207
206
  const args = parseArgs(argv);
@@ -216,8 +215,11 @@ export async function main(argv , input = process.stdin)
216
215
  }
217
216
  const yesRefused = homeYesRefusal(process.cwd(), args.yes);
218
217
  if (yesRefused) { console.error(yesRefused); return 1; }
219
- const apiKey = resolveApiKey();
220
- if (!apiKey) {
218
+ const interactive = Boolean(input.isTTY) && args.rest.length === 0;
219
+ const synqaKey0 = resolveApiKey(process.env, { envKey: args.envKey }) ?? "";
220
+ // A terminal session with no usable Synqa key shows the welcome screen and signs in first.
221
+ const needSignIn = interactive && !looksLikeKey(synqaKey0);
222
+ if (!synqaKey0 && !interactive) {
221
223
  console.error(`No API key. Run "synqa login" or set SYNQA_API_KEY. (config: ${configPath()})`);
222
224
  return 1;
223
225
  }
@@ -226,9 +228,8 @@ export async function main(argv , input = process.stdin)
226
228
  const tier = findTier(args.model || readConfig().tier || DEFAULT_TIER);
227
229
  if (!tier) { console.error(`Unknown tier "${args.model}". Use blink, sonata or aeon.`); return 1; }
228
230
  const effort = args.effort ? findEffort(args.effort) : "default";
229
- if (!effort) { console.error(`Unknown effort "${args.effort}". Use default, low or medium.`); return 1; }
231
+ if (!effort) { console.error(`Unknown effort "${args.effort}". Use default, low, medium or high.`); return 1; }
230
232
 
231
- const interactive = Boolean(input.isTTY) && args.rest.length === 0;
232
233
  // On a terminal the prompt frame reads the keys (for the / menu) and hands them to readline.
233
234
  const framed = interactive && ui.tty && ui.unicode;
234
235
  let statusLines = () => [];
@@ -253,33 +254,72 @@ export async function main(argv , input = process.stdin)
253
254
  return /^y(es)?$/i.test(ans.trim());
254
255
  };
255
256
 
257
+ // The arrow-key controls: every key goes to the selector or the masked prompt, none to readline.
258
+ const keySource = keys ?? (input.isTTY ? streamKeys(input) : undefined);
259
+ const pick = keySource
260
+ ? (items , title ) => pickOne(items, { keys: keySource, out: term, style, unicode: ui.unicode, title })
261
+ : undefined;
262
+ const secret = keySource ? (p ) => readMasked(p, { keys: keySource, out: term, unicode: ui.unicode }) : undefined;
263
+ const ask = interactive ? async (p ) => { try { return (await rl.question(p)).trim(); } catch { return undefined; } } : undefined;
264
+
256
265
  const cwd = process.cwd();
266
+ const sp = startProvider(synqaKey0, process.env, args.envKey);
257
267
  const st = {
258
- apiKey, cwd, tier, effort, history: [], journal: new EditJournal(),
268
+ apiKey: sp.apiKey, cwd, tier, effort, history: [], journal: new EditJournal(),
259
269
  total: { prompt: 0, completion: 0 }, last: { prompt: 0, completion: 0 }, routing: "?", notesChars: 0, notesShown: 0,
270
+ providerId: sp.providerId, baseUrl: sp.baseUrl, modelId: sp.modelId, mode: "ask",
260
271
  };
261
272
  st.history = freshHistory(st);
262
- const ctx = { cwd, confirm, yes: args.yes, color: style.color, journal: st.journal, show: (t ) => say(inCard(t)), get notesLoaded() { return st.notesChars > 0; } };
273
+ const ctx = {
274
+ cwd, confirm, yes: args.yes, color: style.color, journal: st.journal, show: (t ) => say(inCard(t)),
275
+ get notesLoaded() { return st.notesChars > 0; },
276
+ get mode() { return st.mode; },
277
+ };
278
+ const external = () => (st.providerId ?? "synqa") !== "synqa";
279
+ const providerInfo = () => (external() ? { name: providerDef(st.providerId )?.name ?? "Custom endpoint", model: st.modelId } : undefined);
280
+
281
+ // Hooks: shell commands at SessionStart, PreToolUse, PostToolUse and Stop. They never print errors.
282
+ const fire = async (event , payload = {}) => {
283
+ if (st.mode === "plan" && (event === "PostToolUse" || event === "Stop")) return []; // plan mode runs nothing after the fact
284
+ try { return await runHooks(loadHooks(cwd), event, { cwd, ...payload }, cwd, { env: bashEnv(process.env) }); } catch { return []; }
285
+ };
286
+ const toolHooks = {
287
+ pre: async (name , a ) => {
288
+ const blocked = (await fire("PreToolUse", { tool: name, input: a.slice(0, 4000) })).find((h) => h.blocked);
289
+ return blocked ? blocked.reason ?? "stopped by a PreToolUse hook" : undefined;
290
+ },
291
+ post: async (name , a , result ) => { await fire("PostToolUse", { tool: name, input: a.slice(0, 4000), result: result.slice(0, 4000) }); },
292
+ };
263
293
 
264
294
  // Known balance for the footer and status line; refreshed in the background after each turn.
265
295
  let balance = { kind: "unavailable" };
296
+ let latestVersion ;
266
297
  // The status line under the input: only known values (tier, workspace, context, tokens, credit).
267
298
  statusLines = () => renderStatusLines({
268
299
  tier: st.tier, effort: st.effort, cwd, tokens: st.total.prompt + st.total.completion,
269
300
  contextTokens: st.last.prompt || undefined, contextMax: CONTEXT_TOKENS, routing: st.routing, balance,
301
+ provider: providerInfo(), mode: MODE_LABEL[st.mode ?? "ask"], updateHint: latestVersion ? `update ${latestVersion}: /update` : undefined,
270
302
  }, ui);
271
303
  const refresh = () => { frame?.redraw(); };
304
+ // Shift+Tab cycles the permission mode; the footer shows it.
305
+ if (keys) keys.onShiftTab = () => { st.mode = nextMode(st.mode ?? "ask"); };
272
306
  // Raw mode and bracketed paste are always switched off again, also after a crash.
273
307
  const { restore, done } = wireSession({ spinner, keys, rl, oneShot: !interactive });
274
- process.stdout.on("resize", () => refresh());
275
- const loadBalance = async () => balanceFrom(await fetchAccount(apiKey, undefined, BALANCE_TIMEOUT_MS));
308
+ // 'resize' (not SIGWINCH) fires on Windows, macOS and Linux; a changed width redraws the frame.
309
+ process.stdout.on("resize", () => { if (frame) frame.onResize(); else refresh(); });
310
+ // The balance belongs to Synqa Cloud: it is never asked with a secondary provider's key.
311
+ const loadBalance = async () => {
312
+ const key = resolveApiKey(process.env, { envKey: args.envKey });
313
+ return key ? balanceFrom(await fetchAccount(key, undefined, BALANCE_TIMEOUT_MS)) : { kind: "unavailable" };
314
+ };
276
315
  const refreshBalance = () => { void loadBalance().then((b) => { balance = b; }, () => {}); };
277
316
 
278
317
  const turn = async (text ) => {
279
318
  st.history.push({ role: "user", content: text });
280
319
  try {
281
320
  const r = await runAgent({
282
- apiKey, tier: st.tier, effort: st.effort, history: st.history, ctx,
321
+ apiKey: st.apiKey, tier: st.tier, effort: st.effort, history: st.history, ctx,
322
+ baseUrl: external() ? st.baseUrl : undefined, modelId: external() ? st.modelId : undefined, hooks: toolHooks,
283
323
  events: chrome
284
324
  ? {
285
325
  onRequest: () => renderer.requestStart(),
@@ -303,41 +343,74 @@ export async function main(argv , input = process.stdin)
303
343
  renderer.turnEnd();
304
344
  const footer = formatFooter({
305
345
  ttftMs: r.ttftMs, tokPerSec: r.tokPerSec, routing: st.routing,
306
- creditLeft: balance.kind === "ok" ? balance.account.creditRemaining : undefined,
346
+ creditLeft: balance.kind === "ok" && !external() ? balance.account.creditRemaining : undefined,
307
347
  }, ui);
308
348
  if (footer) say(stone(style, footer));
309
- refreshBalance();
349
+ if (!external()) refreshBalance();
310
350
  } else {
311
351
  process.stdout.write("\n");
312
352
  }
313
353
  } catch (err) {
314
- spinner.stop();
354
+ spinner.stop(); // the thinking timer ends on an error or an abort too
315
355
  const msg = sanitize(err instanceof Error ? err.message : String(err));
316
- const envNote = err instanceof ApiError && err.status === 401 && process.env.SYNQA_API_KEY?.trim() ? `\n${ENV_KEY_NOTE}` : "";
356
+ const envNote = err instanceof ApiError && err.status === 401 && !external() && process.env.SYNQA_API_KEY?.trim() ? `\n${ENV_KEY_NOTE}` : "";
317
357
  console.error(`\n${chrome ? accent(style, "error") : "error"}: ${msg}${envNote}`);
318
358
  }
359
+ await fire("Stop", { tokens: st.total.prompt + st.total.completion });
319
360
  refresh();
320
361
  };
321
362
 
363
+ // Welcome screen and the Synqa-first gate: chat and every other provider stay locked until a
364
+ // valid Synqa Cloud key is saved. Returns false when the person cancels.
365
+ const bannerInfo = (locked ) => ({ version: VERSION, tier: st.tier, effort: st.effort, cwd, notesChars: st.notesChars, balance, warnings: locked ? [] : startWarnings(process.env, cwd, undefined, apiKeyFromEnv()), provider: providerInfo(), locked });
366
+ const apiKeyFromEnv = () => {
367
+ const e = process.env.SYNQA_API_KEY?.trim();
368
+ return Boolean(e) && resolveApiKey(process.env, { envKey: args.envKey }) === e;
369
+ };
370
+ const signIn = async () => {
371
+ if (!secret) return false;
372
+ say("");
373
+ for (const line of renderBanner(bannerInfo(true), ui)) say(line);
374
+ say("");
375
+ const key = await unlockGate({ secret, print: (m) => say(dim(style, m)) });
376
+ if (!key) return false;
377
+ const p = startProvider(key, process.env, args.envKey);
378
+ st.apiKey = p.apiKey; st.providerId = p.providerId; st.baseUrl = p.baseUrl; st.modelId = p.modelId;
379
+ st.history = freshHistory(st);
380
+ say(dim(style, "Signed in to Synqa Cloud."));
381
+ return true;
382
+ };
383
+
322
384
  try {
323
385
  if (!interactive) {
324
386
  const prompt = args.rest.join(" ");
325
387
  if (!prompt) { console.error("Nothing to do. Pass a task or run in a terminal."); return 1; }
326
388
  announceNotes(st, say);
327
- for (const w of startWarnings(process.env, cwd)) console.error(`Warning: ${w}`);
389
+ for (const w of startWarnings(process.env, cwd, undefined, apiKeyFromEnv())) console.error(`Warning: ${w}`);
390
+ await fire("SessionStart", { source: "one-shot" });
328
391
  await turn(prompt);
329
392
  say(dim(style, formatStatus(statusParts(st))));
330
393
  return 0;
331
394
  }
332
395
 
396
+ if (needSignIn) {
397
+ if (!(await signIn())) { say(dim(style, "Not signed in. Run synqa again to sign in to Synqa Cloud.")); return 1; }
398
+ }
333
399
  // The balance may take a moment; the banner waits at most BALANCE_TIMEOUT_MS for it.
334
- balance = await loadBalance();
335
- const warnings = startWarnings(process.env, cwd);
400
+ if (!external()) balance = await loadBalance();
336
401
  say("");
337
- for (const line of renderBanner({ version: VERSION, tier: st.tier, effort: st.effort, cwd, notesChars: st.notesChars, balance, warnings }, ui)) say(line);
402
+ if (!needSignIn) for (const line of renderBanner(bannerInfo(false), ui)) say(line);
338
403
  say("");
339
404
  if (!frame) say(dim(style, "Type /help for commands."));
340
405
  announceNotes(st, (t) => say(dim(style, t)));
406
+ await fire("SessionStart", { source: "startup" });
407
+ // A quiet, short check for a newer release; any failure is silent and nothing waits for it.
408
+ if (chrome && !process.env.SYNQA_NO_UPDATE_CHECK) void checkForUpdate(VERSION).then((v) => { latestVersion = v; if (v) refresh(); }, () => {});
409
+ const io = {
410
+ print: say, confirm, style, ui, pick, secret, ask, envKey: args.envKey,
411
+ hooks: { list: () => loadHooks(cwd), toggle: (id ) => toggleHook(cwd, id) },
412
+ update: { current: VERSION, get latest() { return latestVersion; }, run: () => runUpdate() },
413
+ };
341
414
  for (;;) {
342
415
  // Without the frame the status is printed above each prompt instead.
343
416
  if (!frame) say(dim(style, formatStatus(statusParts(st))));
@@ -346,9 +419,14 @@ export async function main(argv , input = process.stdin)
346
419
  line = (frame ? await frame.ask(promptText(ui)) : keys ? await keys.askPlain(`${accent(style, ui.unicode ? "❯" : ">")} `) : await rl.question(`${accent(style, ui.unicode ? "❯" : ">")} `)).trim();
347
420
  } catch { break; }
348
421
  if (!line) continue;
349
- const r = await runCommand(line, st, { print: say, confirm, style, ui });
422
+ const r = await runCommand(line, st, io);
350
423
  refresh();
351
424
  if (r.exit) break;
425
+ if (r.relogin) {
426
+ // A full logout locks the session: back to the sign-in prompt, nothing else works until then.
427
+ if (!(await signIn())) { say(dim(style, "Locked. Run synqa again to sign in.")); break; }
428
+ continue;
429
+ }
352
430
  if (!r.handled) await turn(line);
353
431
  }
354
432
  return 0;
package/dist/modes.js ADDED
@@ -0,0 +1,20 @@
1
+ // Permission modes of the session, cycled with Shift+Tab at the prompt and shown in the footer.
2
+ // ask every file write and edit asks first (the default)
3
+ // auto file writes and edits inside the workspace are applied without a question, like --yes
4
+ // (shell commands, secret files and files that can run code later still ask)
5
+ // plan no file is written and no command is run: the agent can only read and describe a plan
6
+ export const MODES = ["ask", "auto", "plan"] ;
7
+
8
+
9
+ export const MODE_LABEL = {
10
+ ask: "ask before edits",
11
+ auto: "auto-accept edits",
12
+ plan: "plan only",
13
+ };
14
+
15
+ export function nextMode(m ) {
16
+ return MODES[(MODES.indexOf(m) + 1) % MODES.length] ;
17
+ }
18
+
19
+ // Tools that change something; plan mode refuses them.
20
+ export const MUTATING_TOOLS = ["write_file", "edit_file", "bash"];