@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 +145 -57
- package/bin/synqa.js +0 -0
- package/dist/agent.js +19 -5
- package/dist/api.js +46 -4
- package/dist/commands.js +150 -30
- package/dist/compact.js +4 -1
- package/dist/config.js +52 -9
- package/dist/hooks.js +219 -0
- package/dist/main.js +275 -91
- package/dist/modes.js +20 -0
- package/dist/paths.js +157 -38
- package/dist/prompt.js +381 -0
- package/dist/providers.js +234 -0
- package/dist/sanitize.js +6 -0
- package/dist/select.js +131 -0
- package/dist/statusbar.js +16 -9
- package/dist/theme.js +47 -7
- package/dist/tiers.js +1 -1
- package/dist/tools.js +133 -44
- package/dist/ui.js +152 -72
- package/dist/update.js +71 -0
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -1,18 +1,115 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { runAgent } from "./agent.js";
|
|
3
|
-
import { checkKey } from "./api.js";
|
|
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
|
-
import { unsafeStartDir } from "./paths.js";
|
|
8
|
+
import { isHomeDir, isSensitivePath, unsafeStartDir } from "./paths.js";
|
|
9
|
+
|
|
10
|
+
|
|
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";
|
|
20
|
+
|
|
21
|
+
// A workspace inside a protected area (~/.dotfiles, ~/.config/nvim, ~/Library/...): every file is
|
|
22
|
+
// refused there, so say why once at start-up instead of leaving the user with refusals.
|
|
23
|
+
export function protectedWorkspaceWarning(cwd , home ) {
|
|
24
|
+
if (!isSensitivePath(join(cwd, "probe.txt"), undefined, home)) return undefined;
|
|
25
|
+
return "this folder is inside a protected area (a dot folder or Library in your home folder, or a credentials folder), where tools keep keys and tokens. The agent cannot read, write or search files here. Copy the project to a normal folder to work on it.";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The start-up notes both the session banner and a one-shot run print.
|
|
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) {
|
|
31
|
+
const out = [];
|
|
32
|
+
if (envInUse && env.SYNQA_API_KEY?.trim()) out.push("using the key from SYNQA_API_KEY (not the one saved by synqa login)");
|
|
33
|
+
const inProtected = protectedWorkspaceWarning(cwd, home);
|
|
34
|
+
if (inProtected) out.push(inProtected);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
// Keys from a terminal always go through a PromptFrame, framed or not, so pasted text can never
|
|
41
|
+
// answer a y/N question; the frame (rules, status, / menu) is drawn only when `framed`. Questions
|
|
42
|
+
// and the bracketed paste switch go to the terminal: stdout when it is one, else stderr, so
|
|
43
|
+
// `synqa "task" | tee log` still asks on the screen and still tells pasted text from typing.
|
|
44
|
+
export function openSessionInput(o
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
) {
|
|
48
|
+
const tty = Boolean(o.stdin.isTTY);
|
|
49
|
+
const term = o.stdout.isTTY || !o.stderr.isTTY ? o.stdout : o.stderr;
|
|
50
|
+
const keys = tty ? new PromptFrame(o.stdin, term, o.ui, o.below, o.promptWidth, { paste: Boolean(term.isTTY) }) : undefined;
|
|
51
|
+
const rl = createInterface({ input: keys ? keys.input : (o.stdin ), output: term, terminal: tty });
|
|
52
|
+
keys?.start(rl);
|
|
53
|
+
return { rl, keys, term };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
// Raw mode and bracketed paste are switched off on every way out: a normal exit, an uncaught
|
|
59
|
+
// exception and an unhandled rejection (then the error is printed and the exit code is 1).
|
|
60
|
+
export function installTerminalRestore(
|
|
61
|
+
restore , proc = process,
|
|
62
|
+
exit = (c) => process.exit(c), report = (s) => console.error(s),
|
|
63
|
+
) {
|
|
64
|
+
proc.on("exit", restore);
|
|
65
|
+
const crash = (err ) => {
|
|
66
|
+
restore();
|
|
67
|
+
report(`\nerror: ${sanitize(err instanceof Error ? err.message : String(err))}`);
|
|
68
|
+
exit(1);
|
|
69
|
+
};
|
|
70
|
+
proc.on("uncaughtException", crash);
|
|
71
|
+
proc.on("unhandledRejection", crash);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The session's terminal wiring, in one place so it is tested as main uses it: restore stops the
|
|
75
|
+
// spinner and gives the keys back, on every way out. In a one-shot run on a terminal, a Ctrl+C the
|
|
76
|
+
// key router saw (it closes readline) ends the process with 130, the shell's code for an
|
|
77
|
+
// interrupt. Readline closing for any other reason (end of piped input, /dev/null) is not an
|
|
78
|
+
// interrupt: without a terminal there is no key router and no handler at all.
|
|
79
|
+
export function wireSession(o
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
) {
|
|
83
|
+
const exit = o.exit ?? ((c ) => process.exit(c));
|
|
84
|
+
const report = o.report ?? ((s ) => console.error(s));
|
|
85
|
+
const restore = () => { o.spinner.stop(); o.keys?.stop(); };
|
|
86
|
+
installTerminalRestore(restore, o.proc ?? process, exit, report);
|
|
87
|
+
let finished = false;
|
|
88
|
+
const keys = o.keys;
|
|
89
|
+
if (o.oneShot && keys) {
|
|
90
|
+
o.rl.on("close", () => {
|
|
91
|
+
if (finished || !keys.interrupted) return;
|
|
92
|
+
restore();
|
|
93
|
+
report("\ninterrupted");
|
|
94
|
+
exit(130);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return { restore, done: () => { finished = true; } };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --yes in the home folder would let the agent write shell startup files, launch agents and the
|
|
101
|
+
// like without a question, so it is refused there (the tools also ignore it, as a second guard).
|
|
102
|
+
export function homeYesRefusal(cwd , yes , home ) {
|
|
103
|
+
return yes && isHomeDir(cwd, home) ? "--yes is not allowed in your home folder. Run without --yes, or cd into a project folder." : undefined;
|
|
104
|
+
}
|
|
9
105
|
import { sanitize } from "./sanitize.js";
|
|
10
|
-
import { formatStatus
|
|
11
|
-
import { DEFAULT_TIER, findEffort, findTier, routingLabel } from "./tiers.js";
|
|
106
|
+
import { formatStatus } from "./statusbar.js";
|
|
107
|
+
import { CONTEXT_TOKENS, DEFAULT_TIER, findEffort, findTier, routingLabel } from "./tiers.js";
|
|
12
108
|
import { accent, dim, stone } from "./theme.js";
|
|
13
109
|
|
|
14
110
|
import { fetchAccount } from "./account.js";
|
|
15
|
-
import { balanceFrom, formatFooter, makeUi, readVersion, renderBanner, Spinner, styleOf, TurnRenderer } from "./ui.js";
|
|
111
|
+
import { balanceFrom, formatFooter, makeUi, promptText, readVersion, renderBanner, renderStatusLines, Spinner, styleOf, textWidth, TurnRenderer } from "./ui.js";
|
|
112
|
+
import { PromptFrame } from "./prompt.js";
|
|
16
113
|
|
|
17
114
|
|
|
18
115
|
const VERSION = readVersion();
|
|
@@ -23,50 +120,31 @@ const BALANCE_TIMEOUT_MS = 2000;
|
|
|
23
120
|
const HELP = `Usage:
|
|
24
121
|
synqa interactive session in the current folder
|
|
25
122
|
synqa "do something" one-shot task
|
|
26
|
-
synqa login save your API key
|
|
123
|
+
synqa login save your Synqa API key
|
|
27
124
|
Options:
|
|
28
|
-
-y, --yes do not ask before
|
|
125
|
+
-y, --yes do not ask before ordinary file writes and edits
|
|
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
|
|
29
129
|
-m, --model <tier> blink | sonata | aeon (default blink)
|
|
30
|
-
-e, --effort <level> default | low | medium (default default)
|
|
31
|
-
--allow-home allow starting in your home folder or the filesystem root
|
|
130
|
+
-e, --effort <level> default | low | medium | high (default default)
|
|
32
131
|
-h, --help -v, --version
|
|
33
|
-
In a session
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
for (const ch of chunk) {
|
|
50
|
-
if (ch === "\r" || ch === "\n") {
|
|
51
|
-
stdin.setRawMode(false);
|
|
52
|
-
stdin.pause();
|
|
53
|
-
stdin.off("data", onData);
|
|
54
|
-
process.stdout.write("\n");
|
|
55
|
-
resolve(value.trim());
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
if (ch === "\u0003") {
|
|
59
|
-
stdin.setRawMode(false);
|
|
60
|
-
process.stdout.write("\n");
|
|
61
|
-
reject(new Error("cancelled"));
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
if (ch === "\u007f" || ch === "\b") value = value.slice(0, -1);
|
|
65
|
-
else if (ch >= " ") value += ch;
|
|
66
|
-
}
|
|
67
|
-
};
|
|
68
|
-
stdin.on("data", onData);
|
|
69
|
-
});
|
|
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).`;
|
|
135
|
+
|
|
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.";
|
|
139
|
+
|
|
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;
|
|
70
148
|
}
|
|
71
149
|
|
|
72
150
|
// The error text can come from the network, so it is sanitized before printing.
|
|
@@ -91,14 +169,15 @@ export async function login() {
|
|
|
91
169
|
return 0;
|
|
92
170
|
}
|
|
93
171
|
|
|
94
|
-
|
|
172
|
+
|
|
95
173
|
|
|
96
174
|
export function parseArgs(argv ) {
|
|
97
|
-
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: [] };
|
|
98
176
|
for (let i = 0; i < argv.length; i++) {
|
|
99
177
|
const t = argv[i] ;
|
|
100
178
|
if (t === "-y" || t === "--yes") a.yes = true;
|
|
101
|
-
else if (t === "--
|
|
179
|
+
else if (t === "--env-key") a.envKey = true;
|
|
180
|
+
else if (t === "--allow-home") a.allowHome = true; // accepted and ignored since 1.1.1
|
|
102
181
|
else if (t === "-h" || t === "--help") a.help = true;
|
|
103
182
|
else if (t === "-v" || t === "--version") a.version = true;
|
|
104
183
|
else if (t === "-m" || t === "--model") a.model = argv[++i] ?? "";
|
|
@@ -108,19 +187,39 @@ export function parseArgs(argv ) {
|
|
|
108
187
|
return a;
|
|
109
188
|
}
|
|
110
189
|
|
|
111
|
-
|
|
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
|
+
|
|
204
|
+
// `input` is the key source; tests pass a stream that ends at once, like `< /dev/null`.
|
|
205
|
+
export async function main(argv , input = process.stdin) {
|
|
112
206
|
const args = parseArgs(argv);
|
|
113
207
|
if (args.help) { console.log(HELP); return 0; }
|
|
114
208
|
if (args.version) { console.log(VERSION); return 0; }
|
|
115
209
|
if (args.rest[0] === "login") return login();
|
|
116
210
|
|
|
117
211
|
const unsafe = unsafeStartDir(process.cwd());
|
|
118
|
-
if (unsafe
|
|
119
|
-
console.error(`
|
|
212
|
+
if (unsafe) {
|
|
213
|
+
console.error(`Synqa does not start in ${unsafe} (the whole disk would be its workspace). cd into a project folder first.`);
|
|
120
214
|
return 1;
|
|
121
215
|
}
|
|
122
|
-
const
|
|
123
|
-
if (
|
|
216
|
+
const yesRefused = homeYesRefusal(process.cwd(), args.yes);
|
|
217
|
+
if (yesRefused) { console.error(yesRefused); return 1; }
|
|
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) {
|
|
124
223
|
console.error(`No API key. Run "synqa login" or set SYNQA_API_KEY. (config: ${configPath()})`);
|
|
125
224
|
return 1;
|
|
126
225
|
}
|
|
@@ -129,10 +228,15 @@ export async function main(argv ) {
|
|
|
129
228
|
const tier = findTier(args.model || readConfig().tier || DEFAULT_TIER);
|
|
130
229
|
if (!tier) { console.error(`Unknown tier "${args.model}". Use blink, sonata or aeon.`); return 1; }
|
|
131
230
|
const effort = args.effort ? findEffort(args.effort) : "default";
|
|
132
|
-
if (!effort) { console.error(`Unknown effort "${args.effort}". Use default, low or
|
|
231
|
+
if (!effort) { console.error(`Unknown effort "${args.effort}". Use default, low, medium or high.`); return 1; }
|
|
133
232
|
|
|
134
|
-
|
|
135
|
-
const
|
|
233
|
+
// On a terminal the prompt frame reads the keys (for the / menu) and hands them to readline.
|
|
234
|
+
const framed = interactive && ui.tty && ui.unicode;
|
|
235
|
+
let statusLines = () => [];
|
|
236
|
+
const { rl, keys, term } = openSessionInput({
|
|
237
|
+
stdin: input, stdout: process.stdout, stderr: process.stderr, ui, framed, below: () => statusLines(), promptWidth: textWidth(ui.unicode ? "❯ " : "> "),
|
|
238
|
+
});
|
|
239
|
+
const frame = framed ? keys : undefined;
|
|
136
240
|
const say = (s ) => console.log(s);
|
|
137
241
|
// Chrome (spinner, cards, footer) is for interactive terminals only. Pipes and one-shot runs get
|
|
138
242
|
// the plain text output they had before.
|
|
@@ -143,44 +247,79 @@ export async function main(argv ) {
|
|
|
143
247
|
// `detail` is the coloured diff the tools built; the question is sanitized, the diff is not
|
|
144
248
|
// (the tools already escaped every file character in it).
|
|
145
249
|
const confirm = async (q , detail ) => {
|
|
146
|
-
if (!
|
|
250
|
+
if (!input.isTTY) return false;
|
|
147
251
|
spinner.stop();
|
|
148
|
-
if (detail)
|
|
252
|
+
if (detail) term.write(inCard(detail) + "\n"); // with the question, on the terminal
|
|
149
253
|
const ans = await rl.question(`${accent(style, "?")} ${sanitize(q)} [y/N] `);
|
|
150
254
|
return /^y(es)?$/i.test(ans.trim());
|
|
151
255
|
};
|
|
152
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
|
+
|
|
153
265
|
const cwd = process.cwd();
|
|
266
|
+
const sp = startProvider(synqaKey0, process.env, args.envKey);
|
|
154
267
|
const st = {
|
|
155
|
-
apiKey, cwd, tier, effort, history: [], journal: new EditJournal(),
|
|
268
|
+
apiKey: sp.apiKey, cwd, tier, effort, history: [], journal: new EditJournal(),
|
|
156
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",
|
|
157
271
|
};
|
|
158
272
|
st.history = freshHistory(st);
|
|
159
|
-
const ctx = {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
process.stdout,
|
|
164
|
-
interactive && Boolean(process.stdout.isTTY) && process.env.TERM !== "dumb",
|
|
165
|
-
(s) => dim(style, s),
|
|
166
|
-
);
|
|
167
|
-
const refresh = () => {
|
|
168
|
-
if (bar.enabled) bar.update(formatStatus(statusParts(st), (process.stdout.columns ?? 80) - 1));
|
|
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; },
|
|
169
277
|
};
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
process.stdout.on("resize", () => { bar.resize(); refresh(); });
|
|
278
|
+
const external = () => (st.providerId ?? "synqa") !== "synqa";
|
|
279
|
+
const providerInfo = () => (external() ? { name: providerDef(st.providerId )?.name ?? "Custom endpoint", model: st.modelId } : undefined);
|
|
173
280
|
|
|
174
|
-
//
|
|
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
|
+
};
|
|
293
|
+
|
|
294
|
+
// Known balance for the footer and status line; refreshed in the background after each turn.
|
|
175
295
|
let balance = { kind: "unavailable" };
|
|
176
|
-
|
|
296
|
+
let latestVersion ;
|
|
297
|
+
// The status line under the input: only known values (tier, workspace, context, tokens, credit).
|
|
298
|
+
statusLines = () => renderStatusLines({
|
|
299
|
+
tier: st.tier, effort: st.effort, cwd, tokens: st.total.prompt + st.total.completion,
|
|
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,
|
|
302
|
+
}, ui);
|
|
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"); };
|
|
306
|
+
// Raw mode and bracketed paste are always switched off again, also after a crash.
|
|
307
|
+
const { restore, done } = wireSession({ spinner, keys, rl, oneShot: !interactive });
|
|
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
|
+
};
|
|
177
315
|
const refreshBalance = () => { void loadBalance().then((b) => { balance = b; }, () => {}); };
|
|
178
316
|
|
|
179
317
|
const turn = async (text ) => {
|
|
180
318
|
st.history.push({ role: "user", content: text });
|
|
181
319
|
try {
|
|
182
320
|
const r = await runAgent({
|
|
183
|
-
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,
|
|
184
323
|
events: chrome
|
|
185
324
|
? {
|
|
186
325
|
onRequest: () => renderer.requestStart(),
|
|
@@ -204,50 +343,95 @@ export async function main(argv ) {
|
|
|
204
343
|
renderer.turnEnd();
|
|
205
344
|
const footer = formatFooter({
|
|
206
345
|
ttftMs: r.ttftMs, tokPerSec: r.tokPerSec, routing: st.routing,
|
|
207
|
-
creditLeft: balance.kind === "ok" ? balance.account.creditRemaining : undefined,
|
|
346
|
+
creditLeft: balance.kind === "ok" && !external() ? balance.account.creditRemaining : undefined,
|
|
208
347
|
}, ui);
|
|
209
348
|
if (footer) say(stone(style, footer));
|
|
210
|
-
refreshBalance();
|
|
349
|
+
if (!external()) refreshBalance();
|
|
211
350
|
} else {
|
|
212
351
|
process.stdout.write("\n");
|
|
213
352
|
}
|
|
214
353
|
} catch (err) {
|
|
215
|
-
spinner.stop();
|
|
216
|
-
|
|
354
|
+
spinner.stop(); // the thinking timer ends on an error or an abort too
|
|
355
|
+
const msg = sanitize(err instanceof Error ? err.message : String(err));
|
|
356
|
+
const envNote = err instanceof ApiError && err.status === 401 && !external() && process.env.SYNQA_API_KEY?.trim() ? `\n${ENV_KEY_NOTE}` : "";
|
|
357
|
+
console.error(`\n${chrome ? accent(style, "error") : "error"}: ${msg}${envNote}`);
|
|
217
358
|
}
|
|
359
|
+
await fire("Stop", { tokens: st.total.prompt + st.total.completion });
|
|
218
360
|
refresh();
|
|
219
361
|
};
|
|
220
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
|
+
|
|
221
384
|
try {
|
|
222
385
|
if (!interactive) {
|
|
223
386
|
const prompt = args.rest.join(" ");
|
|
224
387
|
if (!prompt) { console.error("Nothing to do. Pass a task or run in a terminal."); return 1; }
|
|
225
388
|
announceNotes(st, say);
|
|
389
|
+
for (const w of startWarnings(process.env, cwd, undefined, apiKeyFromEnv())) console.error(`Warning: ${w}`);
|
|
390
|
+
await fire("SessionStart", { source: "one-shot" });
|
|
226
391
|
await turn(prompt);
|
|
227
392
|
say(dim(style, formatStatus(statusParts(st))));
|
|
228
393
|
return 0;
|
|
229
394
|
}
|
|
230
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
|
+
}
|
|
231
399
|
// The balance may take a moment; the banner waits at most BALANCE_TIMEOUT_MS for it.
|
|
232
|
-
balance = await loadBalance();
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
400
|
+
if (!external()) balance = await loadBalance();
|
|
401
|
+
say("");
|
|
402
|
+
if (!needSignIn) for (const line of renderBanner(bannerInfo(false), ui)) say(line);
|
|
403
|
+
say("");
|
|
404
|
+
if (!frame) say(dim(style, "Type /help for commands."));
|
|
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
|
+
};
|
|
238
414
|
for (;;) {
|
|
239
|
-
// Without
|
|
240
|
-
if (!
|
|
415
|
+
// Without the frame the status is printed above each prompt instead.
|
|
416
|
+
if (!frame) say(dim(style, formatStatus(statusParts(st))));
|
|
241
417
|
let line ;
|
|
242
|
-
try {
|
|
418
|
+
try {
|
|
419
|
+
line = (frame ? await frame.ask(promptText(ui)) : keys ? await keys.askPlain(`${accent(style, ui.unicode ? "❯" : ">")} `) : await rl.question(`${accent(style, ui.unicode ? "❯" : ">")} `)).trim();
|
|
420
|
+
} catch { break; }
|
|
243
421
|
if (!line) continue;
|
|
244
|
-
const r = await runCommand(line, st,
|
|
422
|
+
const r = await runCommand(line, st, io);
|
|
245
423
|
refresh();
|
|
246
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
|
+
}
|
|
247
430
|
if (!r.handled) await turn(line);
|
|
248
431
|
}
|
|
249
432
|
return 0;
|
|
250
433
|
} finally {
|
|
434
|
+
done(); // a normal end: closing readline below is not an interrupt
|
|
251
435
|
restore();
|
|
252
436
|
rl.close();
|
|
253
437
|
}
|
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"];
|