@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/main.js CHANGED
@@ -1,14 +1,22 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { runAgent } from "./agent.js";
3
- import { ApiError, checkKey } from "./api.js";
3
+ import { AbortedError, 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;
@@ -99,7 +108,7 @@ import { CONTEXT_TOKENS, DEFAULT_TIER, findEffort, findTier, routingLabel } from
99
108
  import { accent, dim, stone } from "./theme.js";
100
109
 
101
110
  import { fetchAccount } from "./account.js";
102
- import { balanceFrom, formatFooter, makeUi, promptText, readVersion, renderBanner, renderStatusLines, Spinner, styleOf, textWidth, TurnRenderer } from "./ui.js";
111
+ import { balanceFrom, formatFooter, makeUi, promptText, readVersion, renderBanner, renderStatusLines, Spinner, styleOf, textWidth, ToolProgressLine, TurnRenderer } from "./ui.js";
103
112
  import { PromptFrame } from "./prompt.js";
104
113
 
105
114
 
@@ -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 = () => [];
@@ -241,45 +242,99 @@ export async function main(argv , input = process.stdin)
241
242
  // the plain text output they had before.
242
243
  const chrome = interactive && ui.tty;
243
244
  const spinner = new Spinner(process.stdout, { enabled: chrome, ui });
244
- const renderer = new TurnRenderer(process.stdout, ui, spinner);
245
+ const progress = new ToolProgressLine(process.stdout, { enabled: chrome, ui });
246
+ const renderer = new TurnRenderer(process.stdout, ui, spinner, progress);
245
247
  const inCard = (t ) => (chrome ? renderer.body(t) : t);
246
248
  // `detail` is the coloured diff the tools built; the question is sanitized, the diff is not
247
249
  // (the tools already escaped every file character in it).
248
250
  const confirm = async (q , detail ) => {
249
251
  if (!input.isTTY) return false;
250
252
  spinner.stop();
253
+ progress.stop();
251
254
  if (detail) term.write(inCard(detail) + "\n"); // with the question, on the terminal
252
- const ans = await rl.question(`${accent(style, "?")} ${sanitize(q)} [y/N] `);
255
+ // The turn lock lets keys through for the length of the question.
256
+ const ask = () => rl.question(`${accent(style, "?")} ${sanitize(q)} [y/N] `);
257
+ const ans = keys ? await keys.withKeys(ask) : await ask();
253
258
  return /^y(es)?$/i.test(ans.trim());
254
259
  };
255
260
 
261
+ // The arrow-key controls: every key goes to the selector or the masked prompt, none to readline.
262
+ const keySource = keys ?? (input.isTTY ? streamKeys(input) : undefined);
263
+ const pick = keySource
264
+ ? (items , title ) => pickOne(items, { keys: keySource, out: term, style, unicode: ui.unicode, title })
265
+ : undefined;
266
+ const secret = keySource ? (p ) => readMasked(p, { keys: keySource, out: term, unicode: ui.unicode }) : undefined;
267
+ const ask = interactive ? async (p ) => { try { return (await rl.question(p)).trim(); } catch { return undefined; } } : undefined;
268
+
256
269
  const cwd = process.cwd();
270
+ const sp = startProvider(synqaKey0, process.env, args.envKey);
257
271
  const st = {
258
- apiKey, cwd, tier, effort, history: [], journal: new EditJournal(),
272
+ apiKey: sp.apiKey, cwd, tier, effort, history: [], journal: new EditJournal(),
259
273
  total: { prompt: 0, completion: 0 }, last: { prompt: 0, completion: 0 }, routing: "?", notesChars: 0, notesShown: 0,
274
+ providerId: sp.providerId, baseUrl: sp.baseUrl, modelId: sp.modelId, mode: "ask",
260
275
  };
261
276
  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; } };
277
+ const ctx = {
278
+ cwd, confirm, yes: args.yes, color: style.color, journal: st.journal, show: (t ) => say(inCard(t)),
279
+ get notesLoaded() { return st.notesChars > 0; },
280
+ get mode() { return st.mode; },
281
+ };
282
+ const external = () => (st.providerId ?? "synqa") !== "synqa";
283
+ const providerInfo = () => (external() ? { name: providerDef(st.providerId )?.name ?? "Custom endpoint", model: st.modelId } : undefined);
284
+
285
+ // Hooks: shell commands at SessionStart, PreToolUse, PostToolUse and Stop. They never print errors.
286
+ const fire = async (event , payload = {}) => {
287
+ if (st.mode === "plan" && (event === "PostToolUse" || event === "Stop")) return []; // plan mode runs nothing after the fact
288
+ try { return await runHooks(loadHooks(cwd), event, { cwd, ...payload }, cwd, { env: bashEnv(process.env) }); } catch { return []; }
289
+ };
290
+ const toolHooks = {
291
+ pre: async (name , a ) => {
292
+ const blocked = (await fire("PreToolUse", { tool: name, input: a.slice(0, 4000) })).find((h) => h.blocked);
293
+ return blocked ? blocked.reason ?? "stopped by a PreToolUse hook" : undefined;
294
+ },
295
+ post: async (name , a , result ) => { await fire("PostToolUse", { tool: name, input: a.slice(0, 4000), result: result.slice(0, 4000) }); },
296
+ };
263
297
 
264
298
  // Known balance for the footer and status line; refreshed in the background after each turn.
265
299
  let balance = { kind: "unavailable" };
300
+ let latestVersion ;
266
301
  // The status line under the input: only known values (tier, workspace, context, tokens, credit).
267
302
  statusLines = () => renderStatusLines({
268
303
  tier: st.tier, effort: st.effort, cwd, tokens: st.total.prompt + st.total.completion,
269
304
  contextTokens: st.last.prompt || undefined, contextMax: CONTEXT_TOKENS, routing: st.routing, balance,
305
+ provider: providerInfo(), mode: MODE_LABEL[st.mode ?? "ask"], updateHint: latestVersion ? `update ${latestVersion}: /update` : undefined,
270
306
  }, ui);
271
307
  const refresh = () => { frame?.redraw(); };
308
+ // Shift+Tab cycles the permission mode; the footer shows it.
309
+ if (keys) keys.onShiftTab = () => { st.mode = nextMode(st.mode ?? "ask"); };
272
310
  // Raw mode and bracketed paste are always switched off again, also after a crash.
273
311
  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));
312
+ // 'resize' (not SIGWINCH) fires on Windows, macOS and Linux; a changed width redraws the frame.
313
+ process.stdout.on("resize", () => { if (frame) frame.onResize(); else refresh(); });
314
+ // The balance belongs to Synqa Cloud: it is never asked with a secondary provider's key.
315
+ const loadBalance = async () => {
316
+ const key = resolveApiKey(process.env, { envKey: args.envKey });
317
+ return key ? balanceFrom(await fetchAccount(key, undefined, BALANCE_TIMEOUT_MS)) : { kind: "unavailable" };
318
+ };
276
319
  const refreshBalance = () => { void loadBalance().then((b) => { balance = b; }, () => {}); };
277
320
 
278
- const turn = async (text ) => {
321
+ // Returns true when the person stopped the turn with Esc or Ctrl+C.
322
+ const turn = async (text ) => {
279
323
  st.history.push({ role: "user", content: text });
324
+ const ac = new AbortController();
325
+ let stopped = false;
326
+ // While the turn runs the keyboard is locked: nothing typed is echoed; Esc or Ctrl+C stops the
327
+ // request. A second press, with the turn still not ended (a long shell command), leaves.
328
+ keys?.beginTurn(() => {
329
+ if (ac.signal.aborted) { restore(); process.exit(130); }
330
+ stopped = true;
331
+ ac.abort();
332
+ });
280
333
  try {
281
334
  const r = await runAgent({
282
- apiKey, tier: st.tier, effort: st.effort, history: st.history, ctx,
335
+ apiKey: st.apiKey, tier: st.tier, effort: st.effort, history: st.history, ctx,
336
+ baseUrl: external() ? st.baseUrl : undefined, modelId: external() ? st.modelId : undefined, hooks: toolHooks,
337
+ signal: ac.signal,
283
338
  events: chrome
284
339
  ? {
285
340
  onRequest: () => renderer.requestStart(),
@@ -287,11 +342,14 @@ export async function main(argv , input = process.stdin)
287
342
  onTool: (name, a) => renderer.toolStart(name, a),
288
343
  onToolResult: (name, _a, result) => renderer.toolResult(name, result),
289
344
  onTurn: () => renderer.requestEnd(),
345
+ onToolProgress: (p) => renderer.toolProgress(p),
346
+ onNotice: (t) => { renderer.requestEnd(); say(dim(style, `[${t}]`)); },
290
347
  }
291
348
  : {
292
349
  onText: (t) => process.stdout.write(sanitize(t)),
293
350
  onTool: (name, a) => process.stdout.write(`\n${dim(style, `> ${sanitize(name)} ${sanitize(a.length > 120 ? a.slice(0, 117) + "..." : a)}`)}\n`),
294
351
  onTurn: () => {},
352
+ onNotice: (t) => console.error(`[${t}]`),
295
353
  },
296
354
  });
297
355
  st.total = { prompt: st.total.prompt + r.total.prompt, completion: st.total.completion + r.total.completion };
@@ -303,20 +361,51 @@ export async function main(argv , input = process.stdin)
303
361
  renderer.turnEnd();
304
362
  const footer = formatFooter({
305
363
  ttftMs: r.ttftMs, tokPerSec: r.tokPerSec, routing: st.routing,
306
- creditLeft: balance.kind === "ok" ? balance.account.creditRemaining : undefined,
364
+ creditLeft: balance.kind === "ok" && !external() ? balance.account.creditRemaining : undefined,
307
365
  }, ui);
308
366
  if (footer) say(stone(style, footer));
309
- refreshBalance();
367
+ if (!external()) refreshBalance();
310
368
  } else {
311
369
  process.stdout.write("\n");
312
370
  }
313
371
  } catch (err) {
314
- spinner.stop();
315
- 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}` : "";
317
- console.error(`\n${chrome ? accent(style, "error") : "error"}: ${msg}${envNote}`);
372
+ spinner.stop(); // the thinking timer ends on an error or an abort too
373
+ if (chrome) renderer.turnEnd(); // progress line off, cursor at the start of a line
374
+ if (err instanceof AbortedError) {
375
+ console.error(`\n${dim(style, "interrupted")}`);
376
+ } else {
377
+ const msg = sanitize(err instanceof Error ? err.message : String(err));
378
+ const envNote = err instanceof ApiError && err.status === 401 && !external() && process.env.SYNQA_API_KEY?.trim() ? `\n${ENV_KEY_NOTE}` : "";
379
+ console.error(`\n${chrome ? accent(style, "error") : "error"}: ${msg}${envNote}`);
380
+ }
381
+ } finally {
382
+ keys?.endTurn(); // the keyboard is unlocked on every way out
383
+ progress.stop();
318
384
  }
385
+ await fire("Stop", { tokens: st.total.prompt + st.total.completion });
319
386
  refresh();
387
+ return stopped;
388
+ };
389
+
390
+ // Welcome screen and the Synqa-first gate: chat and every other provider stay locked until a
391
+ // valid Synqa Cloud key is saved. Returns false when the person cancels.
392
+ 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 });
393
+ const apiKeyFromEnv = () => {
394
+ const e = process.env.SYNQA_API_KEY?.trim();
395
+ return Boolean(e) && resolveApiKey(process.env, { envKey: args.envKey }) === e;
396
+ };
397
+ const signIn = async () => {
398
+ if (!secret) return false;
399
+ say("");
400
+ for (const line of renderBanner(bannerInfo(true), ui)) say(line);
401
+ say("");
402
+ const key = await unlockGate({ secret, print: (m) => say(dim(style, m)) });
403
+ if (!key) return false;
404
+ const p = startProvider(key, process.env, args.envKey);
405
+ st.apiKey = p.apiKey; st.providerId = p.providerId; st.baseUrl = p.baseUrl; st.modelId = p.modelId;
406
+ st.history = freshHistory(st);
407
+ say(dim(style, "Signed in to Synqa Cloud."));
408
+ return true;
320
409
  };
321
410
 
322
411
  try {
@@ -324,20 +413,31 @@ export async function main(argv , input = process.stdin)
324
413
  const prompt = args.rest.join(" ");
325
414
  if (!prompt) { console.error("Nothing to do. Pass a task or run in a terminal."); return 1; }
326
415
  announceNotes(st, say);
327
- for (const w of startWarnings(process.env, cwd)) console.error(`Warning: ${w}`);
328
- await turn(prompt);
416
+ for (const w of startWarnings(process.env, cwd, undefined, apiKeyFromEnv())) console.error(`Warning: ${w}`);
417
+ await fire("SessionStart", { source: "one-shot" });
418
+ const stopped = await turn(prompt);
329
419
  say(dim(style, formatStatus(statusParts(st))));
330
- return 0;
420
+ return stopped ? 130 : 0;
331
421
  }
332
422
 
423
+ if (needSignIn) {
424
+ if (!(await signIn())) { say(dim(style, "Not signed in. Run synqa again to sign in to Synqa Cloud.")); return 1; }
425
+ }
333
426
  // 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);
427
+ if (!external()) balance = await loadBalance();
336
428
  say("");
337
- for (const line of renderBanner({ version: VERSION, tier: st.tier, effort: st.effort, cwd, notesChars: st.notesChars, balance, warnings }, ui)) say(line);
429
+ if (!needSignIn) for (const line of renderBanner(bannerInfo(false), ui)) say(line);
338
430
  say("");
339
431
  if (!frame) say(dim(style, "Type /help for commands."));
340
432
  announceNotes(st, (t) => say(dim(style, t)));
433
+ await fire("SessionStart", { source: "startup" });
434
+ // A quiet, short check for a newer release; any failure is silent and nothing waits for it.
435
+ if (chrome && !process.env.SYNQA_NO_UPDATE_CHECK) void checkForUpdate(VERSION).then((v) => { latestVersion = v; if (v) refresh(); }, () => {});
436
+ const io = {
437
+ print: say, confirm, style, ui, pick, secret, ask, envKey: args.envKey,
438
+ hooks: { list: () => loadHooks(cwd), toggle: (id ) => toggleHook(cwd, id) },
439
+ update: { current: VERSION, get latest() { return latestVersion; }, run: () => runUpdate() },
440
+ };
341
441
  for (;;) {
342
442
  // Without the frame the status is printed above each prompt instead.
343
443
  if (!frame) say(dim(style, formatStatus(statusParts(st))));
@@ -346,9 +446,14 @@ export async function main(argv , input = process.stdin)
346
446
  line = (frame ? await frame.ask(promptText(ui)) : keys ? await keys.askPlain(`${accent(style, ui.unicode ? "❯" : ">")} `) : await rl.question(`${accent(style, ui.unicode ? "❯" : ">")} `)).trim();
347
447
  } catch { break; }
348
448
  if (!line) continue;
349
- const r = await runCommand(line, st, { print: say, confirm, style, ui });
449
+ const r = await runCommand(line, st, io);
350
450
  refresh();
351
451
  if (r.exit) break;
452
+ if (r.relogin) {
453
+ // A full logout locks the session: back to the sign-in prompt, nothing else works until then.
454
+ if (!(await signIn())) { say(dim(style, "Locked. Run synqa again to sign in.")); break; }
455
+ continue;
456
+ }
352
457
  if (!r.handled) await turn(line);
353
458
  }
354
459
  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"];
package/dist/paths.js CHANGED
@@ -135,13 +135,17 @@ function hasRun(parts , run ) {
135
135
  return false;
136
136
  }
137
137
 
138
- const slashed = (p ) => fold(p.split(sep).join("/")).replace(/\/+$/, "");
138
+ // One slash form for every comparison. On win32 both separators count and the drive letter and
139
+ // every name are folded, so a Windows AppData folder in any letter case matches. The platform is
140
+ // a parameter so the Windows rules are tested on any machine.
141
+ const slashed = (p , platform = process.platform) =>
142
+ fold(platform === "win32" ? p.replace(/\\/g, "/") : p.split(sep).join("/")).replace(/\/+$/, "");
139
143
 
140
144
  // The home folder, as given and with symlinks resolved, folded.
141
- function homeForms(home ) {
142
- const out = [slashed(home)];
145
+ function homeForms(home , platform = process.platform) {
146
+ const out = [slashed(home, platform)];
143
147
  try {
144
- out.push(slashed(realpathSync(home)));
148
+ out.push(slashed(realpathSync(home), platform));
145
149
  } catch {
146
150
  // a home that does not exist is compared as given
147
151
  }
@@ -152,25 +156,28 @@ function homeForms(home ) {
152
156
  // ~/.cargo/**, any tool's credential store, known or not) and everything under
153
157
  // ~/Library is protected. This is the structural rule: it does not depend on naming each store.
154
158
  // A project's own dot files (~/proj/.github, ~/proj/.eslintrc) are below a normal folder and stay usable.
155
- export function homeHidden(abs , home = homedir()) {
156
- const norm = slashed(abs);
157
- for (const h of homeForms(home)) {
159
+ // On Windows the same rule covers AppData (browser profiles, tokens, credential stores) and the
160
+ // per-user registry hive NTUSER.DAT with its log and backup files.
161
+ export function homeHidden(abs , home = homedir(), platform = process.platform) {
162
+ const norm = slashed(abs, platform);
163
+ for (const h of homeForms(home, platform)) {
158
164
  if (!norm.startsWith(h + "/")) continue;
159
165
  const first = norm.slice(h.length + 1).split("/")[0] ?? "";
160
166
  if (first.startsWith(".") || first === "library") return true;
167
+ if (platform === "win32" && (first === "appdata" || first.startsWith("ntuser.dat") || first === "ntuser.ini")) return true;
161
168
  }
162
169
  return false;
163
170
  }
164
171
 
165
172
  // True when `cwd` is the home folder itself.
166
- export function isHomeDir(cwd , home = homedir()) {
167
- let real = slashed(cwd);
173
+ export function isHomeDir(cwd , home = homedir(), platform = process.platform) {
174
+ let real = slashed(cwd, platform);
168
175
  try {
169
- real = slashed(realpathSync(cwd));
176
+ real = slashed(realpathSync(cwd), platform);
170
177
  } catch {
171
178
  // compared as given
172
179
  }
173
- return homeForms(home).includes(real) || homeForms(home).includes(slashed(cwd));
180
+ return homeForms(home, platform).includes(real) || homeForms(home, platform).includes(slashed(cwd, platform));
174
181
  }
175
182
 
176
183
  // Files and folders that hold secrets: the home folder's dot entries and Library (homeHidden),
@@ -178,13 +185,13 @@ export function isHomeDir(cwd , home = homedir()) {
178
185
  // profiles, histories, and this CLI's own config (which holds the API key). Names only, no file is
179
186
  // opened. Every segment is folded first (see fold). read_file, write_file and edit_file refuse
180
187
  // these outright; grep and glob skip them.
181
- export function isSensitivePath(abs , cfg = configDir(), home = homedir()) {
182
- if (homeHidden(abs, home)) return true;
183
- const norm = slashed(abs);
188
+ export function isSensitivePath(abs , cfg = configDir(), home = homedir(), platform = process.platform) {
189
+ if (homeHidden(abs, home, platform)) return true;
190
+ const norm = slashed(abs, platform);
184
191
  const parts = norm.split("/").filter(Boolean);
185
192
  if (parts.some((d) => DIR_SET.has(d))) return true;
186
193
  if (SENSITIVE_RUNS.some((run) => hasRun(parts, run))) return true;
187
- const c = slashed(cfg);
194
+ const c = slashed(cfg, platform);
188
195
  if (c && (norm === c || norm.startsWith(c + "/"))) return true;
189
196
  const name = parts[parts.length - 1] ?? "";
190
197
  // Remotes can hold tokens: .git/config, .git/config.worktree, .git/worktrees/<name>/config, at any depth.