@synqa/cli 0.0.0-stage → 1.1.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/init.js ADDED
@@ -0,0 +1,177 @@
1
+ // /init: scan the project and write a starter AGENTS.md. No model call; the output is a plain
2
+ // template filled from what is on disk, for the user to edit.
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { basename, extname, join } from "node:path";
5
+ import { snapshot } from "./journal.js";
6
+
7
+ import { confine, writeConfined } from "./paths.js";
8
+ import { sanitize } from "./sanitize.js";
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+ const SKIP = new Set([".git", "node_modules", "dist", "build", "target", "__pycache__", ".venv", "venv", ".next", "coverage", ".cache"]);
22
+ const MAX_FILES = 3000;
23
+ const MAX_DEPTH = 4;
24
+
25
+ const LANGS = {
26
+ ".ts": "TypeScript", ".tsx": "TypeScript", ".js": "JavaScript", ".jsx": "JavaScript", ".mjs": "JavaScript", ".cjs": "JavaScript",
27
+ ".py": "Python", ".go": "Go", ".rs": "Rust", ".java": "Java", ".kt": "Kotlin", ".rb": "Ruby", ".php": "PHP",
28
+ ".c": "C", ".h": "C", ".cpp": "C++", ".cs": "C#", ".swift": "Swift", ".sh": "Shell", ".html": "HTML", ".css": "CSS",
29
+ };
30
+
31
+ const DIR_NOTES = {
32
+ src: "source code", lib: "library code", app: "application code", cli: "command line code", bin: "executables",
33
+ test: "tests", tests: "tests", spec: "tests", docs: "documentation", doc: "documentation", scripts: "helper scripts",
34
+ public: "static files served as is", static: "static files served as is", assets: "images, fonts and other assets",
35
+ website: "website", web: "web front end", api: "API code", config: "configuration", examples: "examples",
36
+ };
37
+
38
+ const MANIFESTS = ["package.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod", "Gemfile", "pom.xml", "build.gradle", "Makefile", "Dockerfile", "docker-compose.yml"];
39
+
40
+ function readText(p , max = 200_000) {
41
+ try {
42
+ return readFileSync(p, "utf8").slice(0, max);
43
+ } catch {
44
+ return "";
45
+ }
46
+ }
47
+
48
+ function countLanguages(root ) {
49
+ const counts = new Map ();
50
+ let seen = 0;
51
+ const walk = (dir , depth ) => {
52
+ if (depth > MAX_DEPTH || seen >= MAX_FILES) return;
53
+ let entries;
54
+ try {
55
+ entries = readdirSync(dir, { withFileTypes: true });
56
+ } catch {
57
+ return;
58
+ }
59
+ for (const e of entries) {
60
+ if (SKIP.has(e.name) || e.name.startsWith(".")) continue;
61
+ if (e.isDirectory()) walk(join(dir, e.name), depth + 1);
62
+ else if (e.isFile()) {
63
+ seen++;
64
+ const lang = LANGS[extname(e.name).toLowerCase()];
65
+ if (lang) counts.set(lang, (counts.get(lang) ?? 0) + 1);
66
+ }
67
+ }
68
+ };
69
+ walk(root, 0);
70
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 4).map(([l]) => l);
71
+ }
72
+
73
+ function nodeCommands(root , pkg ) {
74
+ const pm = existsSync(join(root, "pnpm-lock.yaml")) ? "pnpm" : existsSync(join(root, "yarn.lock")) ? "yarn" : existsSync(join(root, "bun.lockb")) ? "bun" : "npm";
75
+ const scripts = pkg.scripts && typeof pkg.scripts === "object" ? (pkg.scripts ) : {};
76
+ const out = [];
77
+ const add = (label , name ) => {
78
+ if (typeof scripts[name] !== "string") return;
79
+ out.push({ label, cmd: name === "test" && pm === "npm" ? "npm test" : name === "start" && pm === "npm" ? "npm start" : `${pm} run ${name}` });
80
+ };
81
+ add("Run", "dev");
82
+ add("Run", "start");
83
+ add("Build", "build");
84
+ add("Test", "test");
85
+ add("Lint", "lint");
86
+ return out;
87
+ }
88
+
89
+ function makeTargets(text ) {
90
+ const set = new Set ();
91
+ for (const m of text.matchAll(/^([A-Za-z0-9_.-]+):(?!=)/gm)) set.add(m[1] );
92
+ return set;
93
+ }
94
+
95
+ export function scanProject(root ) {
96
+ const manifests = MANIFESTS.filter((m) => existsSync(join(root, m)));
97
+ let name = basename(root);
98
+ const commands = [];
99
+ if (manifests.includes("package.json")) {
100
+ try {
101
+ const pkg = JSON.parse(readText(join(root, "package.json"))) ;
102
+ if (typeof pkg.name === "string" && pkg.name) name = pkg.name;
103
+ commands.push(...nodeCommands(root, pkg));
104
+ } catch {
105
+ // A broken package.json is not this command's problem; the scan just skips it.
106
+ }
107
+ }
108
+ const hasTestsDir = ["test", "tests"].some((d) => existsSync(join(root, d)));
109
+ if (manifests.includes("pyproject.toml") || manifests.includes("requirements.txt")) {
110
+ if (hasTestsDir) commands.push({ label: "Test", cmd: "python -m pytest" });
111
+ }
112
+ if (manifests.includes("Cargo.toml")) commands.push({ label: "Build", cmd: "cargo build" }, { label: "Test", cmd: "cargo test" });
113
+ if (manifests.includes("go.mod")) commands.push({ label: "Build", cmd: "go build ./..." }, { label: "Test", cmd: "go test ./..." });
114
+ if (manifests.includes("Makefile")) {
115
+ const targets = makeTargets(readText(join(root, "Makefile")));
116
+ for (const [label, t] of [["Build", "build"], ["Test", "test"], ["Lint", "lint"]] ) {
117
+ if (targets.has(t)) commands.push({ label, cmd: `make ${t}` });
118
+ }
119
+ }
120
+ let dirs = [];
121
+ try {
122
+ dirs = readdirSync(root, { withFileTypes: true })
123
+ .filter((e) => e.isDirectory() && !SKIP.has(e.name) && !e.name.startsWith("."))
124
+ .map((e) => ({ name: sanitize(e.name), note: DIR_NOTES[e.name.toLowerCase()] ?? "" }))
125
+ .sort((a, b) => a.name.localeCompare(b.name))
126
+ .slice(0, 15);
127
+ } catch {
128
+ dirs = [];
129
+ }
130
+ const hasReadme = readdirSync(root).some((f) => /^readme(\.|$)/i.test(f));
131
+ return { name: sanitize(name), languages: countLanguages(root), dirs, manifests, commands, hasReadme };
132
+ }
133
+
134
+ export function renderAgentsMd(s ) {
135
+ const lines = [`# ${s.name}`, "", "Starter notes for the Synqa coding agent. Edit this file: the agent reads it at the start of every session.", ""];
136
+ lines.push("## Overview", "", "Describe in one or two sentences what this project does and who uses it.");
137
+ if (s.languages.length) lines.push("", `Main languages: ${s.languages.join(", ")}.`);
138
+ if (s.hasReadme) lines.push("", "See the README for background.");
139
+ lines.push("", "## Layout", "");
140
+ if (s.dirs.length) for (const d of s.dirs) lines.push(`- \`${d.name}/\`${d.note ? `: ${d.note}` : ""}`);
141
+ else lines.push("- (no sub folders found)");
142
+ if (s.manifests.length) lines.push("", `Project files: ${s.manifests.map((m) => `\`${m}\``).join(", ")}.`);
143
+ lines.push("", "## Commands", "");
144
+ if (s.commands.length) for (const c of s.commands) lines.push(`- ${c.label}: \`${c.cmd}\``);
145
+ else lines.push("- (no build, test or run commands detected: add them here)");
146
+ lines.push("", "## Conventions", "", "- Add your code style, naming and review rules here.", "- Say what the agent must never touch (generated files, secrets, vendored code).", "");
147
+ return lines.join("\n");
148
+ }
149
+
150
+ // The project's AGENTS.md, for the system prompt. Empty when there is none or it cannot be read.
151
+ export function loadNotes(cwd , max = 8000) {
152
+ try {
153
+ const p = confine(cwd, "AGENTS.md");
154
+ return existsSync(p) ? sanitize(readText(p, max)) : "";
155
+ } catch {
156
+ return "";
157
+ }
158
+ }
159
+
160
+
161
+
162
+
163
+
164
+
165
+
166
+
167
+ export async function initProject(cwd , io ) {
168
+ const target = confine(cwd, "AGENTS.md");
169
+ const existing = snapshot(target);
170
+ if (existing !== null && !(await io.confirm("AGENTS.md already exists. Overwrite it with a new starter file?"))) {
171
+ return { status: "kept", message: "AGENTS.md left as it is." };
172
+ }
173
+ const content = renderAgentsMd(scanProject(cwd));
174
+ writeConfined(cwd, target, content);
175
+ if (existing !== "too-large") io.journal?.record({ path: target, before: existing, after: Buffer.from(content) });
176
+ return { status: "written", message: `Wrote AGENTS.md (${content.split("\n").length - 1} lines). Edit it, then run /clear so the agent reads it.${io.journal ? " /undo reverts it." : ""}` };
177
+ }
@@ -0,0 +1,61 @@
1
+ // In-memory journal of the files the agent wrote, so /undo can put the last one back.
2
+ import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
3
+ import { relative } from "node:path";
4
+ import { confine, writeConfined } from "./paths.js";
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+ export const MAX_JOURNAL = 100;
13
+ export const MAX_SNAPSHOT_BYTES = 5_000_000;
14
+
15
+ // The current bytes of a file, null when it does not exist, "too-large" when it is not safe to keep.
16
+ export function snapshot(path ) {
17
+ if (!existsSync(path)) return null;
18
+ if (statSync(path).size > MAX_SNAPSHOT_BYTES) return "too-large";
19
+ return readFileSync(path);
20
+ }
21
+
22
+
23
+
24
+
25
+
26
+ export class EditJournal {
27
+ entries = [];
28
+
29
+ get size() {
30
+ return this.entries.length;
31
+ }
32
+
33
+ record(e ) {
34
+ this.entries.push(e);
35
+ if (this.entries.length > MAX_JOURNAL) this.entries.shift();
36
+ }
37
+
38
+ peek() {
39
+ return this.entries[this.entries.length - 1];
40
+ }
41
+
42
+ // Reverts the newest entry. If the file changed since the agent wrote it (the user edited it, or a
43
+ // command did), the caller must confirm first: `force` is that answer.
44
+ undo(cwd , force ) {
45
+ const e = this.peek();
46
+ if (!e) return { ok: false, reason: "empty", message: "nothing to undo" };
47
+ try {
48
+ const abs = confine(cwd, e.path);
49
+ const current = existsSync(abs) ? readFileSync(abs) : null;
50
+ if (!force && (current === null || !current.equals(e.after))) {
51
+ return { ok: false, reason: "changed-since", message: "the file changed after the agent wrote it" };
52
+ }
53
+ if (e.before === null) rmSync(abs, { force: true });
54
+ else writeConfined(cwd, e.path, e.before);
55
+ } catch (err) {
56
+ return { ok: false, reason: "error", message: err instanceof Error ? err.message : String(err) };
57
+ }
58
+ this.entries.pop();
59
+ return { ok: true, path: relative(cwd, e.path) || e.path, removed: e.before === null, before: e.before, after: e.after };
60
+ }
61
+ }
package/dist/main.js ADDED
@@ -0,0 +1,360 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { runAgent } from "./agent.js";
3
+ import { ApiError, checkKey } from "./api.js";
4
+ import { announceNotes, freshHistory, runCommand, statusParts } from "./commands.js";
5
+
6
+ import { looksLikeKey, readConfig, resolveApiKey, writeConfig, configPath } from "./config.js";
7
+ import { EditJournal } from "./journal.js";
8
+ import { isHomeDir, isSensitivePath, unsafeStartDir } from "./paths.js";
9
+
10
+
11
+ import { join } from "node:path";
12
+
13
+ // A workspace inside a protected area (~/.dotfiles, ~/.config/nvim, ~/Library/...): every file is
14
+ // refused there, so say why once at start-up instead of leaving the user with refusals.
15
+ export function protectedWorkspaceWarning(cwd , home ) {
16
+ if (!isSensitivePath(join(cwd, "probe.txt"), undefined, home)) return undefined;
17
+ 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.";
18
+ }
19
+
20
+ // The start-up notes both the session banner and a one-shot run print.
21
+ export function startWarnings(env , cwd , home ) {
22
+ 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)");
24
+ const inProtected = protectedWorkspaceWarning(cwd, home);
25
+ if (inProtected) out.push(inProtected);
26
+ return out;
27
+ }
28
+
29
+
30
+
31
+ // Keys from a terminal always go through a PromptFrame, framed or not, so pasted text can never
32
+ // answer a y/N question; the frame (rules, status, / menu) is drawn only when `framed`. Questions
33
+ // and the bracketed paste switch go to the terminal: stdout when it is one, else stderr, so
34
+ // `synqa "task" | tee log` still asks on the screen and still tells pasted text from typing.
35
+ export function openSessionInput(o
36
+
37
+
38
+ ) {
39
+ const tty = Boolean(o.stdin.isTTY);
40
+ const term = o.stdout.isTTY || !o.stderr.isTTY ? o.stdout : o.stderr;
41
+ const keys = tty ? new PromptFrame(o.stdin, term, o.ui, o.below, o.promptWidth, { paste: Boolean(term.isTTY) }) : undefined;
42
+ const rl = createInterface({ input: keys ? keys.input : (o.stdin ), output: term, terminal: tty });
43
+ keys?.start(rl);
44
+ return { rl, keys, term };
45
+ }
46
+
47
+
48
+
49
+ // Raw mode and bracketed paste are switched off on every way out: a normal exit, an uncaught
50
+ // exception and an unhandled rejection (then the error is printed and the exit code is 1).
51
+ export function installTerminalRestore(
52
+ restore , proc = process,
53
+ exit = (c) => process.exit(c), report = (s) => console.error(s),
54
+ ) {
55
+ proc.on("exit", restore);
56
+ const crash = (err ) => {
57
+ restore();
58
+ report(`\nerror: ${sanitize(err instanceof Error ? err.message : String(err))}`);
59
+ exit(1);
60
+ };
61
+ proc.on("uncaughtException", crash);
62
+ proc.on("unhandledRejection", crash);
63
+ }
64
+
65
+ // The session's terminal wiring, in one place so it is tested as main uses it: restore stops the
66
+ // spinner and gives the keys back, on every way out. In a one-shot run on a terminal, a Ctrl+C the
67
+ // key router saw (it closes readline) ends the process with 130, the shell's code for an
68
+ // interrupt. Readline closing for any other reason (end of piped input, /dev/null) is not an
69
+ // interrupt: without a terminal there is no key router and no handler at all.
70
+ export function wireSession(o
71
+
72
+
73
+ ) {
74
+ const exit = o.exit ?? ((c ) => process.exit(c));
75
+ const report = o.report ?? ((s ) => console.error(s));
76
+ const restore = () => { o.spinner.stop(); o.keys?.stop(); };
77
+ installTerminalRestore(restore, o.proc ?? process, exit, report);
78
+ let finished = false;
79
+ const keys = o.keys;
80
+ if (o.oneShot && keys) {
81
+ o.rl.on("close", () => {
82
+ if (finished || !keys.interrupted) return;
83
+ restore();
84
+ report("\ninterrupted");
85
+ exit(130);
86
+ });
87
+ }
88
+ return { restore, done: () => { finished = true; } };
89
+ }
90
+
91
+ // --yes in the home folder would let the agent write shell startup files, launch agents and the
92
+ // like without a question, so it is refused there (the tools also ignore it, as a second guard).
93
+ export function homeYesRefusal(cwd , yes , home ) {
94
+ return yes && isHomeDir(cwd, home) ? "--yes is not allowed in your home folder. Run without --yes, or cd into a project folder." : undefined;
95
+ }
96
+ import { sanitize } from "./sanitize.js";
97
+ import { formatStatus } from "./statusbar.js";
98
+ import { CONTEXT_TOKENS, DEFAULT_TIER, findEffort, findTier, routingLabel } from "./tiers.js";
99
+ import { accent, dim, stone } from "./theme.js";
100
+
101
+ import { fetchAccount } from "./account.js";
102
+ import { balanceFrom, formatFooter, makeUi, promptText, readVersion, renderBanner, renderStatusLines, Spinner, styleOf, textWidth, TurnRenderer } from "./ui.js";
103
+ import { PromptFrame } from "./prompt.js";
104
+
105
+
106
+ const VERSION = readVersion();
107
+
108
+ // The balance fetch gets this long at startup and after a turn; the banner never waits longer.
109
+ const BALANCE_TIMEOUT_MS = 2000;
110
+
111
+ const HELP = `Usage:
112
+ synqa interactive session in the current folder
113
+ synqa "do something" one-shot task
114
+ synqa login save your API key
115
+ Options:
116
+ -y, --yes do not ask before ordinary file writes and edits
117
+ (shell commands always ask; not allowed in your home folder)
118
+ -m, --model <tier> blink | sonata | aeon (default blink)
119
+ -e, --effort <level> default | low | medium (default default)
120
+ -h, --help -v, --version
121
+ In a session, type / for the command menu (/model, /effort, /status, /init, /compact, /undo,
122
+ /clear, /help, /exit).`;
123
+
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.";
126
+
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
+ });
162
+ }
163
+
164
+ // The error text can come from the network, so it is sanitized before printing.
165
+ export function loginFailureMessage(err ) {
166
+ return sanitize(`Could not verify the key: ${err instanceof Error ? err.message : String(err)}`);
167
+ }
168
+
169
+ export async function login() {
170
+ const key = await readSecret("Paste your Synqa API key (input is hidden): ");
171
+ if (!looksLikeKey(key)) {
172
+ console.error("That does not look like a Synqa key (expected synqa_live_...).");
173
+ return 1;
174
+ }
175
+ try {
176
+ await checkKey(key);
177
+ } catch (err) {
178
+ console.error(loginFailureMessage(err));
179
+ return 1;
180
+ }
181
+ const file = writeConfig({ ...readConfig(), apiKey: key });
182
+ 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
+ return 0;
186
+ }
187
+
188
+
189
+
190
+ export function parseArgs(argv ) {
191
+ const a = { allowHome: false, yes: false, model: "", effort: "", help: false, version: false, rest: [] };
192
+ for (let i = 0; i < argv.length; i++) {
193
+ const t = argv[i] ;
194
+ if (t === "-y" || t === "--yes") a.yes = true;
195
+ else if (t === "--allow-home") a.allowHome = true; // accepted and ignored since 1.1.1
196
+ else if (t === "-h" || t === "--help") a.help = true;
197
+ else if (t === "-v" || t === "--version") a.version = true;
198
+ else if (t === "-m" || t === "--model") a.model = argv[++i] ?? "";
199
+ else if (t === "-e" || t === "--effort") a.effort = argv[++i] ?? "";
200
+ else a.rest.push(t);
201
+ }
202
+ return a;
203
+ }
204
+
205
+ // `input` is the key source; tests pass a stream that ends at once, like `< /dev/null`.
206
+ export async function main(argv , input = process.stdin) {
207
+ const args = parseArgs(argv);
208
+ if (args.help) { console.log(HELP); return 0; }
209
+ if (args.version) { console.log(VERSION); return 0; }
210
+ if (args.rest[0] === "login") return login();
211
+
212
+ const unsafe = unsafeStartDir(process.cwd());
213
+ if (unsafe) {
214
+ console.error(`Synqa does not start in ${unsafe} (the whole disk would be its workspace). cd into a project folder first.`);
215
+ return 1;
216
+ }
217
+ const yesRefused = homeYesRefusal(process.cwd(), args.yes);
218
+ if (yesRefused) { console.error(yesRefused); return 1; }
219
+ const apiKey = resolveApiKey();
220
+ if (!apiKey) {
221
+ console.error(`No API key. Run "synqa login" or set SYNQA_API_KEY. (config: ${configPath()})`);
222
+ return 1;
223
+ }
224
+ const ui = makeUi(process.env, process.stdout);
225
+ const style = styleOf(ui);
226
+ const tier = findTier(args.model || readConfig().tier || DEFAULT_TIER);
227
+ if (!tier) { console.error(`Unknown tier "${args.model}". Use blink, sonata or aeon.`); return 1; }
228
+ const effort = args.effort ? findEffort(args.effort) : "default";
229
+ if (!effort) { console.error(`Unknown effort "${args.effort}". Use default, low or medium.`); return 1; }
230
+
231
+ const interactive = Boolean(input.isTTY) && args.rest.length === 0;
232
+ // On a terminal the prompt frame reads the keys (for the / menu) and hands them to readline.
233
+ const framed = interactive && ui.tty && ui.unicode;
234
+ let statusLines = () => [];
235
+ const { rl, keys, term } = openSessionInput({
236
+ stdin: input, stdout: process.stdout, stderr: process.stderr, ui, framed, below: () => statusLines(), promptWidth: textWidth(ui.unicode ? "❯ " : "> "),
237
+ });
238
+ const frame = framed ? keys : undefined;
239
+ const say = (s ) => console.log(s);
240
+ // Chrome (spinner, cards, footer) is for interactive terminals only. Pipes and one-shot runs get
241
+ // the plain text output they had before.
242
+ const chrome = interactive && ui.tty;
243
+ const spinner = new Spinner(process.stdout, { enabled: chrome, ui });
244
+ const renderer = new TurnRenderer(process.stdout, ui, spinner);
245
+ const inCard = (t ) => (chrome ? renderer.body(t) : t);
246
+ // `detail` is the coloured diff the tools built; the question is sanitized, the diff is not
247
+ // (the tools already escaped every file character in it).
248
+ const confirm = async (q , detail ) => {
249
+ if (!input.isTTY) return false;
250
+ spinner.stop();
251
+ 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] `);
253
+ return /^y(es)?$/i.test(ans.trim());
254
+ };
255
+
256
+ const cwd = process.cwd();
257
+ const st = {
258
+ apiKey, cwd, tier, effort, history: [], journal: new EditJournal(),
259
+ total: { prompt: 0, completion: 0 }, last: { prompt: 0, completion: 0 }, routing: "?", notesChars: 0, notesShown: 0,
260
+ };
261
+ 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; } };
263
+
264
+ // Known balance for the footer and status line; refreshed in the background after each turn.
265
+ let balance = { kind: "unavailable" };
266
+ // The status line under the input: only known values (tier, workspace, context, tokens, credit).
267
+ statusLines = () => renderStatusLines({
268
+ tier: st.tier, effort: st.effort, cwd, tokens: st.total.prompt + st.total.completion,
269
+ contextTokens: st.last.prompt || undefined, contextMax: CONTEXT_TOKENS, routing: st.routing, balance,
270
+ }, ui);
271
+ const refresh = () => { frame?.redraw(); };
272
+ // Raw mode and bracketed paste are always switched off again, also after a crash.
273
+ 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));
276
+ const refreshBalance = () => { void loadBalance().then((b) => { balance = b; }, () => {}); };
277
+
278
+ const turn = async (text ) => {
279
+ st.history.push({ role: "user", content: text });
280
+ try {
281
+ const r = await runAgent({
282
+ apiKey, tier: st.tier, effort: st.effort, history: st.history, ctx,
283
+ events: chrome
284
+ ? {
285
+ onRequest: () => renderer.requestStart(),
286
+ onText: (t) => renderer.text(t),
287
+ onTool: (name, a) => renderer.toolStart(name, a),
288
+ onToolResult: (name, _a, result) => renderer.toolResult(name, result),
289
+ onTurn: () => renderer.requestEnd(),
290
+ }
291
+ : {
292
+ onText: (t) => process.stdout.write(sanitize(t)),
293
+ onTool: (name, a) => process.stdout.write(`\n${dim(style, `> ${sanitize(name)} ${sanitize(a.length > 120 ? a.slice(0, 117) + "..." : a)}`)}\n`),
294
+ onTurn: () => {},
295
+ },
296
+ });
297
+ st.total = { prompt: st.total.prompt + r.total.prompt, completion: st.total.completion + r.total.completion };
298
+ st.last = r.lastUsage;
299
+ st.routing = routingLabel(r.routing);
300
+ st.ttftMs = r.ttftMs;
301
+ st.tokPerSec = r.tokPerSec;
302
+ if (chrome) {
303
+ renderer.turnEnd();
304
+ const footer = formatFooter({
305
+ ttftMs: r.ttftMs, tokPerSec: r.tokPerSec, routing: st.routing,
306
+ creditLeft: balance.kind === "ok" ? balance.account.creditRemaining : undefined,
307
+ }, ui);
308
+ if (footer) say(stone(style, footer));
309
+ refreshBalance();
310
+ } else {
311
+ process.stdout.write("\n");
312
+ }
313
+ } 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}`);
318
+ }
319
+ refresh();
320
+ };
321
+
322
+ try {
323
+ if (!interactive) {
324
+ const prompt = args.rest.join(" ");
325
+ if (!prompt) { console.error("Nothing to do. Pass a task or run in a terminal."); return 1; }
326
+ announceNotes(st, say);
327
+ for (const w of startWarnings(process.env, cwd)) console.error(`Warning: ${w}`);
328
+ await turn(prompt);
329
+ say(dim(style, formatStatus(statusParts(st))));
330
+ return 0;
331
+ }
332
+
333
+ // 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);
336
+ say("");
337
+ for (const line of renderBanner({ version: VERSION, tier: st.tier, effort: st.effort, cwd, notesChars: st.notesChars, balance, warnings }, ui)) say(line);
338
+ say("");
339
+ if (!frame) say(dim(style, "Type /help for commands."));
340
+ announceNotes(st, (t) => say(dim(style, t)));
341
+ for (;;) {
342
+ // Without the frame the status is printed above each prompt instead.
343
+ if (!frame) say(dim(style, formatStatus(statusParts(st))));
344
+ let line ;
345
+ try {
346
+ line = (frame ? await frame.ask(promptText(ui)) : keys ? await keys.askPlain(`${accent(style, ui.unicode ? "❯" : ">")} `) : await rl.question(`${accent(style, ui.unicode ? "❯" : ">")} `)).trim();
347
+ } catch { break; }
348
+ if (!line) continue;
349
+ const r = await runCommand(line, st, { print: say, confirm, style, ui });
350
+ refresh();
351
+ if (r.exit) break;
352
+ if (!r.handled) await turn(line);
353
+ }
354
+ return 0;
355
+ } finally {
356
+ done(); // a normal end: closing readline below is not an interrupt
357
+ restore();
358
+ rl.close();
359
+ }
360
+ }