ada-agent 0.2.0 → 0.3.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.
Files changed (39) hide show
  1. package/README.md +262 -263
  2. package/bench/README.md +88 -88
  3. package/bench/swebench.mjs +242 -242
  4. package/docs/architecture.md +163 -163
  5. package/docs/architecture.svg +73 -73
  6. package/docs/cloudflare.md +81 -81
  7. package/docs/connectors.md +49 -49
  8. package/docs/integrations.md +62 -62
  9. package/package.json +67 -65
  10. package/skills/aesthetic-direction/SKILL.md +24 -24
  11. package/skills/color-palette/SKILL.md +24 -24
  12. package/skills/component-library/SKILL.md +23 -23
  13. package/skills/dark-mode/SKILL.md +24 -24
  14. package/skills/dashboard-ui/SKILL.md +23 -23
  15. package/skills/design-system/SKILL.md +24 -24
  16. package/skills/design-tokens/SKILL.md +24 -24
  17. package/skills/empty-states/SKILL.md +23 -23
  18. package/skills/hero-section/SKILL.md +23 -23
  19. package/skills/micro-interactions/SKILL.md +23 -23
  20. package/skills/motion-design/SKILL.md +23 -23
  21. package/skills/page-transitions/SKILL.md +23 -23
  22. package/skills/pricing-page/SKILL.md +23 -23
  23. package/skills/scroll-animation/SKILL.md +23 -23
  24. package/skills/skeleton-loader/SKILL.md +23 -23
  25. package/skills/tailwind-theme/SKILL.md +24 -24
  26. package/skills/typography/SKILL.md +24 -24
  27. package/skills/ui-polish/SKILL.md +24 -24
  28. package/skills/ui-review/SKILL.md +24 -24
  29. package/skills/web-fonts/SKILL.md +24 -24
  30. package/src/client/autostart.ts +93 -0
  31. package/src/client/catalog.json +1 -1
  32. package/src/client/cli.ts +1275 -1262
  33. package/src/client/models-dev.ts +106 -106
  34. package/src/selfcheck.ts +404 -390
  35. package/src/server/config.ts +65 -65
  36. package/src/server/providers/openai-compat.ts +78 -78
  37. package/src/server/providers/registry.ts +32 -32
  38. package/src/server/router.ts +33 -33
  39. package/src/shared/types.ts +21 -21
package/src/client/cli.ts CHANGED
@@ -1,1262 +1,1275 @@
1
- // ada client REPL. Talks only to the ada backend.
2
-
3
- import { createInterface } from "node:readline/promises";
4
- import { spawnSync } from "node:child_process";
5
- import { basename, dirname, join, resolve } from "node:path";
6
- import { readFileSync } from "node:fs";
7
- import { fileURLToPath } from "node:url";
8
- import { stdin, stdout } from "node:process";
9
- import OpenAI from "openai";
10
- import { Agent, type ApprovalDecision, type OnApprove } from "./agent.ts";
11
- import { expandPrompt, loadPrompts } from "./prompts.ts";
12
- import { Session, list, type SessionMeta } from "./session.ts";
13
- import { deleteCredential, getCredential, listCredentials } from "../server/credentials.ts";
14
- import { deviceLogin, oauthConfig } from "../server/oauth.ts";
15
- import { addTrust, isTrusted, loadSettings, setActiveAgentPermissions, type Settings } from "./settings.ts";
16
- import { getCommands, loadExtensions } from "./extensions.ts";
17
- import { registerTool, setAsker } from "./tools.ts";
18
- import { addRemoteSkill, loadSkills, registerSkillTool } from "./skills.ts";
19
- import { addConnector, listConnectors, loadMcpServers, removeConnector } from "./mcp.ts";
20
- import { addExtension, selfUpdate } from "./pkg.ts";
21
- import { runTui } from "./tui-mode.ts";
22
- import { loadImage } from "./image.ts";
23
- import { notify, readClipboard, readClipboardImage } from "./platform.ts";
24
- import { undoAll } from "./checkpoint.ts";
25
- import { restore as restoreSnapshot, snapshot } from "./snapshot.ts";
26
- import { catalogText, prefetch } from "./models-dev.ts";
27
- import { renderJobs, startJob } from "./background.ts";
28
- import { renderTodos } from "./todos.ts";
29
- import { track } from "./telemetry.ts";
30
-
31
- type Msg = OpenAI.Chat.Completions.ChatCompletionMessageParam;
32
- type RL = ReturnType<typeof createInterface>;
33
-
34
- const BACKEND = process.env.ADA_BACKEND_URL ?? "http://localhost:8787/v1";
35
-
36
- /** A stored GitHub/Google login token, sent as the bearer so the backend can identify us. */
37
- function identityToken(): string | undefined {
38
- for (const p of ["github", "google"]) {
39
- const c = getCredential(p);
40
- if (c?.type === "oauth" && c.access) return c.access;
41
- }
42
- return undefined;
43
- }
44
-
45
- function clientKey(): string {
46
- return process.env.ADA_CLIENT_KEY ?? identityToken() ?? "dev";
47
- }
48
-
49
- interface Flags {
50
- model?: string;
51
- listModels?: boolean;
52
- cont?: boolean;
53
- resume?: boolean;
54
- yolo?: boolean;
55
- print?: string;
56
- reasoning?: "low" | "medium" | "high";
57
- models?: string[];
58
- json?: boolean;
59
- rpc?: boolean;
60
- tui?: boolean;
61
- strategy?: string;
62
- agent?: string;
63
- }
64
-
65
- function escapeHtml(s: string): string {
66
- return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c] ?? c);
67
- }
68
-
69
- /** Render a session transcript as a self-contained HTML page (for `ada share`). */
70
- function renderTranscript(title: string, messages: Array<{ role?: string; content?: unknown }>): string {
71
- const body = messages
72
- .map((m) => {
73
- const c = m.content;
74
- const text = typeof c === "string" ? c : Array.isArray(c) ? c.map((p) => (p as { text?: string }).text ?? "[image]").join("") : c == null ? "" : JSON.stringify(c);
75
- if (!text.trim()) return "";
76
- return `<div class="msg ${m.role ?? ""}"><div class="role">${escapeHtml(m.role ?? "")}</div><pre>${escapeHtml(text)}</pre></div>`;
77
- })
78
- .join("\n");
79
- return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)} · ada</title><style>
80
- body{background:#0d0f12;color:#e6e9ee;font:14px/1.6 ui-sans-serif,system-ui,sans-serif;max-width:820px;margin:0 auto;padding:32px}
81
- h1{color:#ffaf00;font-size:18px}.msg{margin:16px 0;border-left:3px solid #262b33;padding-left:14px}
82
- .msg.user{border-color:#ffaf00}.msg.assistant{border-color:#3fb950}.msg.tool{border-color:#82aaff}
83
- .role{font:600 11px ui-monospace,monospace;color:#9aa3af;text-transform:uppercase;margin-bottom:4px}
84
- pre{margin:0;white-space:pre-wrap;font:13px/1.6 ui-monospace,monospace;color:#c5cdd6}
85
- </style></head><body><h1>◆ ${escapeHtml(title)}</h1>${body}</body></html>`;
86
- }
87
-
88
- /** Activate a named agent profile (prompt + permission rules) from settings. Returns false if unknown. */
89
- function switchAgent(agent: Agent, name: string, settings: Settings): boolean {
90
- const profile = settings.agents?.[name];
91
- if (!profile) return false;
92
- setActiveAgentPermissions(profile.permissions ?? null);
93
- if (profile.prompt) agent.pushSystem(`You are now acting as the "${name}" agent. ${profile.prompt}`);
94
- return true;
95
- }
96
-
97
- function parseArgs(argv: string[]): Flags {
98
- const f: Flags = {};
99
- for (let i = 0; i < argv.length; i++) {
100
- const a = argv[i];
101
- if (a === "--model") f.model = argv[++i];
102
- else if (a === "--list-models") f.listModels = true;
103
- else if (a === "--continue") f.cont = true;
104
- else if (a === "--resume") f.resume = true;
105
- else if (a === "--yolo") f.yolo = true;
106
- else if (a === "-p" || a === "--print") f.print = argv[++i] ?? "";
107
- else if (a === "--reasoning") {
108
- const v = argv[++i];
109
- if (v === "low" || v === "medium" || v === "high") f.reasoning = v;
110
- } else if (a === "--models") {
111
- f.models = (argv[++i] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
112
- } else if (a === "--json") {
113
- f.json = true;
114
- } else if (a === "--rpc") {
115
- f.rpc = true;
116
- } else if (a === "--tui") {
117
- f.tui = true;
118
- } else if (a === "--strategy") {
119
- f.strategy = argv[++i];
120
- } else if (a === "--agent") {
121
- f.agent = argv[++i];
122
- }
123
- }
124
- return f;
125
- }
126
-
127
- function fuzzyPick(query: string, ids: string[]): string | null {
128
- const q = query.toLowerCase();
129
- const exact = ids.find((id) => id.toLowerCase() === q);
130
- if (exact) return exact;
131
- const subs = ids.filter((id) => id.toLowerCase().includes(q));
132
- if (subs.length) return subs.sort((a, b) => a.length - b.length)[0]!;
133
- return null;
134
- }
135
-
136
- async function fetchModelIds(client: OpenAI): Promise<string[]> {
137
- const ids: string[] = [];
138
- const res = await client.models.list();
139
- for await (const m of res) ids.push(m.id);
140
- return ids.sort();
141
- }
142
-
143
- function reportModelsError(e: unknown): void {
144
- const msg = e instanceof Error ? e.message : String(e);
145
- console.error(`Could not reach the ada backend at ${BACKEND}: ${msg}`);
146
- console.error("Is the backend running? Start it in another terminal: npm run server");
147
- }
148
-
149
- async function printModels(client: OpenAI): Promise<void> {
150
- try {
151
- const ids = await fetchModelIds(client);
152
- console.log(ids.join("\n"));
153
- console.log(`\n${ids.length} models`);
154
- } catch (e) {
155
- reportModelsError(e);
156
- }
157
- }
158
-
159
- async function pickModel(client: OpenAI, rl: RL): Promise<string> {
160
- console.log("Fetching available models…");
161
- let ids: string[] = [];
162
- try {
163
- ids = await fetchModelIds(client);
164
- } catch (e) {
165
- reportModelsError(e);
166
- }
167
- if (!ids.length) return (await rl.question("Enter a model id: ")).trim();
168
- const shown = ids.slice(0, 40);
169
- if (stdin.isTTY) {
170
- const choice = await select(rl, "Select a model (↑/↓ · Enter · Esc to type an id):", shown);
171
- if (choice !== null) return shown[choice]!;
172
- }
173
- shown.forEach((id, i) => console.log(`${String(i + 1).padStart(2)}. ${id}`));
174
- if (ids.length > 40) console.log(`… and ${ids.length - 40} more (type an id directly)`);
175
- const a = (await rl.question("pick # or type a model id: ")).trim();
176
- const n = Number(a);
177
- if (Number.isInteger(n) && n >= 1 && n <= ids.length) return ids[n - 1]!;
178
- return fuzzyPick(a, ids) ?? a;
179
- }
180
-
181
- async function pickSession(rl: RL): Promise<string | null> {
182
- const metas = list();
183
- if (!metas.length) {
184
- console.log("No saved sessions.");
185
- return null;
186
- }
187
- const top = metas.slice(0, 20);
188
- if (stdin.isTTY) {
189
- const choice = await select(rl, "Resume which session? (↑/↓ · Enter · Esc to cancel):", top.map((m) => m.title));
190
- if (choice !== null) return top[choice]?.file ?? null;
191
- return null;
192
- }
193
- top.forEach((m, i) => console.log(`${String(i + 1).padStart(2)}. ${m.title}`));
194
- const a = (await rl.question("resume #: ")).trim();
195
- const idx = Number(a) - 1;
196
- return top[idx]?.file ?? null;
197
- }
198
-
199
- function rawOn(rl: RL, onData: (b: Buffer) => void): void {
200
- rl.pause();
201
- if (stdin.isTTY) stdin.setRawMode(true);
202
- stdin.on("data", onData);
203
- stdin.resume();
204
- }
205
-
206
- function rawOff(rl: RL, onData: (b: Buffer) => void): void {
207
- stdin.off("data", onData);
208
- if (stdin.isTTY) stdin.setRawMode(false);
209
- rl.resume();
210
- }
211
-
212
- /** Decode a raw stdin chunk to a logical key: arrows (or j/k/Tab), enter, esc, ctrl-c, or the literal char. */
213
- function decodeKey(s: string): string {
214
- if (s === "\x1b[A" || s === "k") return "up";
215
- if (s === "\x1b[B" || s === "j" || s === "\t") return "down";
216
- if (s === "\r" || s === "\n") return "enter";
217
- if (s === "\x03") return "ctrl-c";
218
- if (s === "\x1b") return "esc";
219
- return s;
220
- }
221
-
222
- /**
223
- * Read decoded keypresses in raw mode until a handler calls done(). Two robustness points the
224
- * naive version got wrong: (1) a bare ESC may be the head of a split "\x1b[A" arrow sequence on
225
- * Windows/slow ptys, so it's held ~50ms and re-joined with the next chunk before being treated as
226
- * Esc; (2) teardown (listener removal + raw-mode restore + rl.resume) is guaranteed exactly once.
227
- */
228
- function readKeys(rl: RL, onKey: (key: string, done: () => void) => void): void {
229
- let settled = false;
230
- let escTimer: ReturnType<typeof setTimeout> | null = null;
231
- const done = (): void => {
232
- if (settled) return;
233
- settled = true;
234
- if (escTimer) clearTimeout(escTimer);
235
- stdin.off("data", handler);
236
- if (stdin.isTTY) stdin.setRawMode(false);
237
- rl.resume();
238
- };
239
- const emit = (s: string): void => onKey(decodeKey(s), done);
240
- const handler = (buf: Buffer): void => {
241
- let s = buf.toString("utf8");
242
- if (escTimer) {
243
- clearTimeout(escTimer);
244
- escTimer = null;
245
- s = `\x1b${s}`; // re-join the ESC we were holding with this follow-up chunk (arrow keys)
246
- }
247
- if (s === "\x1b") {
248
- escTimer = setTimeout(() => {
249
- escTimer = null;
250
- emit("\x1b");
251
- }, 50);
252
- return;
253
- }
254
- emit(s);
255
- };
256
- rl.pause();
257
- if (stdin.isTTY) stdin.setRawMode(true);
258
- stdin.on("data", handler);
259
- stdin.resume();
260
- }
261
-
262
- /** Arrow-key list selector. Returns the chosen index, or null on Esc / non-TTY (caller falls back). */
263
- async function select(rl: RL, title: string, items: string[]): Promise<number | null> {
264
- if (!stdin.isTTY || !items.length) return null;
265
- let idx = 0;
266
- const draw = (first: boolean): void => {
267
- if (!first) stdout.write(`\x1b[${items.length}A`); // jump back up to redraw in place
268
- for (let i = 0; i < items.length; i++) {
269
- stdout.write(i === idx ? `\x1b[2K\x1b[38;5;214m❯ ${items[i]}\x1b[0m\n` : `\x1b[2K ${items[i]}\n`);
270
- }
271
- };
272
- stdout.write(`${title}\n`);
273
- draw(true);
274
- return await new Promise<number | null>((res) => {
275
- readKeys(rl, (key, done) => {
276
- if (key === "up") {
277
- idx = (idx - 1 + items.length) % items.length;
278
- draw(false);
279
- } else if (key === "down") {
280
- idx = (idx + 1) % items.length;
281
- draw(false);
282
- } else if (key === "enter") {
283
- done();
284
- res(idx);
285
- } else if (key === "esc" || key === "ctrl-c") {
286
- done();
287
- res(null);
288
- }
289
- });
290
- });
291
- }
292
-
293
- type PermMode = "ask" | "plan" | "auto";
294
- type ApproveChoice = "yes" | "auto" | "plan" | "no";
295
-
296
- /**
297
- * Tool-approval prompt. A fixed single-key prompt (no scrolling redraw — that fought the streaming
298
- * transcript and glitched): it states in plain words what permission is being requested + the actual
299
- * target, then reads one key [y]es · [a]uto (run the rest without asking) · [p]lan (switch to plan
300
- * mode, skip this) · [n]o/Esc. `summary` is "<permission phrase>\n<detail>" from the agent.
301
- */
302
- async function approvePrompt(rl: RL, name: string, summary: string): Promise<ApproveChoice> {
303
- const nl = summary.indexOf("\n");
304
- const risk = (nl >= 0 ? summary.slice(0, nl) : summary) || `run the ${name} tool`;
305
- const detail = nl >= 0 ? summary.slice(nl + 1).trim() : "";
306
- const danger = risk.startsWith("");
307
- if (!stdin.isTTY) {
308
- const ans = (await rl.question(`\x1b[33m? ${risk} [y]es / [a]uto / [p]lan / [N]o \x1b[0m`)).trim().toLowerCase();
309
- return ans[0] === "y" ? "yes" : ans[0] === "a" ? "auto" : ans[0] === "p" ? "plan" : "no";
310
- }
311
- const cols = (stdout.columns || 80) - 2;
312
- const head = `${danger ? "\x1b[31m" : "\x1b[33m"}ada wants to ${risk.replace(/^⚠ /, "")}\x1b[0m`;
313
- const det = detail ? ` \x1b[2m${detail.length > cols ? `${detail.slice(0, cols - 1)}…` : detail}\x1b[0m\n` : "";
314
- stdout.write(`\n${danger ? "\x1b[31m⚠\x1b[0m " : ""}${head}\n${det}\x1b[2m[\x1b[0my\x1b[2m]es [\x1b[0ma\x1b[2m]uto [\x1b[0mp\x1b[2m]lan [\x1b[0mn\x1b[2m]o ›\x1b[0m `);
315
- return await new Promise<ApproveChoice>((res) => {
316
- readKeys(rl, (key, done) => {
317
- const k = key.length === 1 ? key.toLowerCase() : key;
318
- const val: ApproveChoice | null = k === "y" || key === "enter" ? "yes" : k === "a" ? "auto" : k === "p" ? "plan" : k === "n" || key === "esc" || key === "ctrl-c" ? "no" : null;
319
- if (!val) return;
320
- done();
321
- stdout.write(`\r\x1b[2K${val === "no" ? "\x1b[31m✗\x1b[0m skipped" : `\x1b[32m✓\x1b[0m ${val === "auto" ? "auto (won't ask again)" : val === "plan" ? "→ plan mode" : "ran"}`}\n`);
322
- res(val);
323
- });
324
- });
325
- }
326
-
327
- function printTree(currentFile: string): void {
328
- const metas = list();
329
- if (!metas.length) {
330
- console.log("No sessions.");
331
- return;
332
- }
333
- const children = new Map<string, SessionMeta[]>();
334
- for (const m of metas) {
335
- if (m.parent) {
336
- const arr = children.get(m.parent) ?? [];
337
- arr.push(m);
338
- children.set(m.parent, arr);
339
- }
340
- }
341
- const rec = (m: SessionMeta, depth: number): void => {
342
- const mark = m.file === currentFile ? "\x1b[38;5;214m●\x1b[0m" : "○";
343
- console.log(`${" ".repeat(depth)}${mark} ${basename(m.file)} \x1b[2m${m.title}\x1b[0m`);
344
- for (const c of children.get(m.file) ?? []) rec(c, depth + 1);
345
- };
346
- for (const m of metas.filter((x) => !x.parent || !metas.some((y) => y.file === x.parent))) rec(m, 0);
347
- }
348
-
349
- function makeClient(): OpenAI {
350
- return new OpenAI({ baseURL: BACKEND, apiKey: clientKey(), maxRetries: 0 });
351
- }
352
-
353
- /** Run the device flow for `provider`; returns true on success (token stored). */
354
- async function loginFlow(provider: string): Promise<boolean> {
355
- const cfg = oauthConfig(provider);
356
- if (!cfg) {
357
- console.log(`No OAuth config for ${provider}. Set ADA_OAUTH_${provider.toUpperCase()}_{CLIENT_ID,DEVICE_URL,TOKEN_URL}.`);
358
- return false;
359
- }
360
- try {
361
- await deviceLogin(provider, cfg, (s) => console.log(s));
362
- return true;
363
- } catch (e) {
364
- console.error(`login failed: ${e instanceof Error ? e.message : e}`);
365
- return false;
366
- }
367
- }
368
-
369
- /** Startup login check: probe the backend; if it says 401, offer to sign in and rebuild the client. */
370
- async function ensureAuth(rl: RL, client: OpenAI): Promise<OpenAI> {
371
- let status: number;
372
- try {
373
- const r = await fetch(`${BACKEND}/whoami`, { headers: { authorization: `Bearer ${clientKey()}` } });
374
- status = r.status;
375
- } catch {
376
- return client; // backend unreachable — the model fetch will report it
377
- }
378
- if (status !== 401) return client; // 200 = already authorized, or backend is open (dev)
379
- const provider = ["github", "google"].find((p) => oauthConfig(p));
380
- if (!provider) {
381
- console.log("\x1b[33mthis backend requires login, but no OAuth provider is configured (set ADA_OAUTH_*).\x1b[0m");
382
- return client;
383
- }
384
- const ans = (await rl.question(`\x1b[33mnot logged in — sign in with ${provider}? [Y/n] \x1b[0m`)).trim().toLowerCase();
385
- if (ans === "n" || ans === "no") return client;
386
- return (await loginFlow(provider)) ? makeClient() : client;
387
- }
388
-
389
- async function authCommand(sub: string, provider?: string): Promise<void> {
390
- if (!provider) {
391
- console.error(`usage: ada ${sub} <provider>`);
392
- console.log(listCredentials().length ? `logged in: ${listCredentials().join(", ")}` : "no stored credentials");
393
- process.exit(1);
394
- }
395
- if (sub === "logout") {
396
- await deleteCredential(provider);
397
- console.log(`logged out ${provider}`);
398
- return;
399
- }
400
- if (!(await loginFlow(provider))) process.exit(1);
401
- }
402
-
403
- /** Gate project-level files (.ada/prompts, AGENTS.md, project settings) behind explicit trust. */
404
- async function ensureTrust(rl: RL): Promise<boolean> {
405
- const cwd = process.cwd();
406
- if (isTrusted(cwd)) return true;
407
- if (!stdin.isTTY) return false; // headless: never load untrusted project files
408
- const ans = (await rl.question(`Trust ${cwd} and load its .ada config (prompts, AGENTS.md, settings)? [y/N] `)).trim().toLowerCase();
409
- if (ans === "y" || ans === "yes") {
410
- addTrust(cwd);
411
- return true;
412
- }
413
- return false;
414
- }
415
-
416
- // ANSI-Shadow "ada", rendered as a truecolor splash. █ = gradient body, box glyphs = drop shadow.
417
- const ADA_ART = [
418
- " █████╗ ██████╗ █████╗ ",
419
- "██╔══██╗██╔══██╗██╔══██╗",
420
- "███████║██║ ██║███████║",
421
- "██╔══██║██║ ██║██╔══██║",
422
- "██║ ██║██████╔╝██║ ██║",
423
- "╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝",
424
- ];
425
- const GRADIENT: [number, number, number][] = [
426
- [255, 214, 92], // gold
427
- [255, 122, 41], // orange
428
- [214, 51, 132], // magenta
429
- ];
430
-
431
- /** Interpolate the gradient stops at t∈[0,1]. */
432
- function gradientAt(t: number): [number, number, number] {
433
- const seg = Math.max(0, Math.min(1, t)) * (GRADIENT.length - 1);
434
- const i = Math.min(Math.floor(seg), GRADIENT.length - 2);
435
- const f = seg - i;
436
- const [a, b] = [GRADIENT[i]!, GRADIENT[i + 1]!];
437
- return [0, 1, 2].map((k) => Math.round(a[k]! + (b[k]! - a[k]!) * f)) as [number, number, number];
438
- }
439
-
440
- function adaVersion(): string {
441
- try {
442
- const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
443
- return (JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { version?: string }).version ?? "0.0.0";
444
- } catch {
445
- return "0.0.0";
446
- }
447
- }
448
-
449
- const TAG = "a coding agent from zero";
450
- const W = Math.max(...ADA_ART.map((l) => l.length));
451
- const H = ADA_ART.length;
452
- const EDGE = 6; // width of the bright leading edge of the light sweep
453
-
454
- /** One logo row at light-sweep position `sweep`. Unlit ahead of the edge, white-hot at it, settled gradient behind. */
455
- function logoRow(line: string, y: number, sweep: number): string {
456
- let s = "\x1b[2K "; // clear line, then indent
457
- [...line].forEach((ch, x) => {
458
- if (ch === " ") return void (s += " ");
459
- if (ch !== "") return void (s += `\x1b[0m\x1b[38;2;92;72;82m${ch}`); // outline = drop shadow
460
- if (x > sweep) return void (s += `\x1b[0m\x1b[38;2;70;55;62m█`); // not yet lit
461
- const [r, g, b] = gradientAt((x / W + y / H) / 2);
462
- const d = sweep - x;
463
- if (d < EDGE) {
464
- const t = 1 - d / EDGE; // 1 at the edge, fading to 0 as it settles
465
- const mix = (c: number): number => Math.round(c + (255 - c) * t * 0.85);
466
- return void (s += `\x1b[1m\x1b[38;2;${mix(r)};${mix(g)};${mix(b)}m█`);
467
- }
468
- return void (s += `\x1b[1m\x1b[38;2;${r};${g};${b}m█`);
469
- });
470
- return s + "\x1b[0m";
471
- }
472
-
473
- const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
474
-
475
- /** Startup splash: a ~400ms left-to-right light sweep over the logo on a TTY; static plain text otherwise. */
476
- async function printBanner(): Promise<void> {
477
- const fancy = stdout.isTTY === true && process.env.NO_COLOR === undefined;
478
- if (!fancy) {
479
- const body = ADA_ART.map((l) => ` ${l}`).join("\n");
480
- stdout.write(`\n${body}\n ${TAG} v${adaVersion()}\n\n`);
481
- return;
482
- }
483
- const frames = 18;
484
- stdout.write("\x1b[?25l\n"); // hide cursor, leading blank line
485
- try {
486
- for (let f = 0; f <= frames; f++) {
487
- const sweep = (f / frames) * (W + EDGE);
488
- if (f > 0) stdout.write(`\x1b[${H}A`); // jump back up to redraw in place
489
- stdout.write(`${ADA_ART.map((line, y) => logoRow(line, y, sweep)).join("\n")}\n`);
490
- if (f < frames) await sleep(400 / frames);
491
- }
492
- } finally {
493
- stdout.write("\x1b[?25h"); // always restore the cursor, even if interrupted
494
- }
495
- stdout.write(` \x1b[2m${TAG}\x1b[0m \x1b[38;2;214;51;132mv${adaVersion()}\x1b[0m\n\n`);
496
- }
497
-
498
- async function main(): Promise<void> {
499
- const sub = process.argv[2];
500
- if (sub === "login" || sub === "logout") {
501
- await authCommand(sub, process.argv[3]);
502
- return;
503
- }
504
- if (sub === "add") {
505
- const spec = process.argv[3];
506
- if (!spec) {
507
- console.error("usage: ada add <git-url | npm-package>");
508
- process.exit(1);
509
- }
510
- try {
511
- addExtension(spec);
512
- } catch (e) {
513
- console.error(e instanceof Error ? e.message : e);
514
- process.exit(1);
515
- }
516
- return;
517
- }
518
- if (sub === "update") {
519
- selfUpdate();
520
- return;
521
- }
522
- if (sub === "mcp") {
523
- const action = process.argv[3] ?? "list";
524
- const name = process.argv[4];
525
- if (action === "list" || action === "ls") {
526
- console.log("Connector catalog (● configured · available):\n");
527
- for (const c of listConnectors()) {
528
- const dot = c.configured ? "\x1b[38;5;214m●\x1b[0m" : "○";
529
- const env = c.needsEnv.length ? ` \x1b[2m(set: ${c.needsEnv.join(", ")})\x1b[0m` : "";
530
- console.log(` ${dot} ${c.name.padEnd(14)} ${c.description}${env}`);
531
- }
532
- console.log("\n ada mcp add <name> · ada mcp remove <name>");
533
- console.log(" custom server: edit .ada/mcp.json — a { command,args } (stdio) or { url } (http) entry");
534
- return;
535
- }
536
- if (action === "add") {
537
- if (!name) {
538
- console.error("usage: ada mcp add <name>");
539
- process.exit(1);
540
- }
541
- const r = addConnector(name);
542
- if (!r.ok) {
543
- console.error(r.error);
544
- process.exit(1);
545
- }
546
- console.log(`\x1b[38;5;214m✓\x1b[0m added "${name}" to .ada/mcp.json`);
547
- if (r.envVars.length) console.log(` set before use: ${r.envVars.join(", ")}`);
548
- return;
549
- }
550
- if (action === "remove" || action === "rm") {
551
- if (!name) {
552
- console.error("usage: ada mcp remove <name>");
553
- process.exit(1);
554
- }
555
- console.log(removeConnector(name) ? `removed "${name}" from .ada/mcp.json` : `"${name}" was not configured`);
556
- return;
557
- }
558
- console.error("usage: ada mcp [list | add <name> | remove <name>]");
559
- process.exit(1);
560
- }
561
- if (sub === "worktree" || sub === "wt") {
562
- const action = process.argv[3] ?? "list";
563
- const git = (...a: string[]): { status: number | null; out: string } => {
564
- const r = spawnSync("git", a, { encoding: "utf8", cwd: process.cwd() });
565
- return { status: r.status, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
566
- };
567
- if (action === "list" || action === "ls") {
568
- const r = git("worktree", "list");
569
- console.log(r.status === 0 ? r.out : "(not a git repo or no worktrees)");
570
- return;
571
- }
572
- if (action === "add" || action === "new") {
573
- const name = process.argv[4];
574
- if (!name) {
575
- console.error("usage: ada worktree add <name>");
576
- process.exit(1);
577
- }
578
- const branch = `ada/${name}`;
579
- const dir = resolve(process.cwd(), "..", `${basename(process.cwd())}-${name}`);
580
- const r = git("worktree", "add", "-b", branch, dir);
581
- if (r.status !== 0) {
582
- console.error(r.out || "git worktree add failed");
583
- process.exit(1);
584
- }
585
- console.log(`\x1b[38;5;214m✓\x1b[0m worktree ${dir}\n branch ${branch} cd "${dir}" && ada`);
586
- return;
587
- }
588
- if (action === "remove" || action === "rm") {
589
- const name = process.argv[4];
590
- if (!name) {
591
- console.error("usage: ada worktree remove <name>");
592
- process.exit(1);
593
- }
594
- const dir = resolve(process.cwd(), "..", `${basename(process.cwd())}-${name}`);
595
- const r = git("worktree", "remove", dir);
596
- console.log(r.status === 0 ? `removed ${dir}` : r.out);
597
- return;
598
- }
599
- console.error("usage: ada worktree [list | add <name> | remove <name>]");
600
- process.exit(1);
601
- }
602
- if (sub === "skill") {
603
- const action = process.argv[3] ?? "list";
604
- if (action === "add") {
605
- const url = process.argv[4];
606
- if (!url) {
607
- console.error("usage: ada skill add <url> (a SKILL.md, or a JSON index of them)");
608
- process.exit(1);
609
- }
610
- try {
611
- const added = await addRemoteSkill(url);
612
- console.log(added.length ? `\x1b[38;5;214m✓\x1b[0m installed: ${added.join(", ")} ~/.ada/skills/` : "no skills found at that URL");
613
- } catch (e) {
614
- console.error(e instanceof Error ? e.message : e);
615
- process.exit(1);
616
- }
617
- return;
618
- }
619
- if (action === "list" || action === "ls") {
620
- for (const s of loadSkills(true)) console.log(` ${s.name.padEnd(22)} ${s.description}`);
621
- return;
622
- }
623
- console.error("usage: ada skill [list | add <url>]");
624
- process.exit(1);
625
- }
626
- if (sub === "catalog") {
627
- // Offline model catalog (curated popular providers) — context limits + pricing, no backend/network.
628
- console.log(catalogText(process.argv[3]));
629
- return;
630
- }
631
- if (sub === "acp") {
632
- // Minimal Agent Client Protocol bridge over stdio (JSON-RPC 2.0, newline-delimited). Scaffold:
633
- // handles initialize + prompt so an ACP-aware editor can drive ada. Extend method names/framing
634
- // to match your client's ACP version.
635
- const trusted = isTrusted(process.cwd());
636
- const settings = loadSettings(trusted);
637
- await loadExtensions(trusted);
638
- registerSkillTool(loadSkills(trusted));
639
- await loadMcpServers(trusted);
640
- const client = makeClient();
641
- let model = process.env.ADA_MODEL || settings.model || "";
642
- if (!model) {
643
- try {
644
- model = (await fetchModelIds(client))[0] ?? "";
645
- } catch {
646
- /* offline */
647
- }
648
- }
649
- const agent = new Agent({ client, model, session: Session.create(), onApprove: async (): Promise<ApprovalDecision> => "yes", autoApprove: true, project: trusted, compactAt: settings.compactAt });
650
- const send = (msg: object): void => void stdout.write(`${JSON.stringify(msg)}\n`);
651
- let buf = "";
652
- stdin.on("data", async (d) => {
653
- buf += d.toString("utf8");
654
- let nl: number;
655
- while ((nl = buf.indexOf("\n")) >= 0) {
656
- const line = buf.slice(0, nl).trim();
657
- buf = buf.slice(nl + 1);
658
- if (!line) continue;
659
- let msg: { id?: number; method?: string; params?: Record<string, unknown> };
660
- try {
661
- msg = JSON.parse(line);
662
- } catch {
663
- continue;
664
- }
665
- if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1, agentCapabilities: { promptCapabilities: {} } } });
666
- else if (msg.method === "session/new" || msg.method === "newSession") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "ada" } });
667
- else if (msg.method === "session/prompt" || msg.method === "prompt") {
668
- const p = msg.params ?? {};
669
- const blocks = (p.prompt ?? p.text) as unknown;
670
- const text = Array.isArray(blocks) ? blocks.map((b) => (b as { text?: string }).text ?? "").join("") : String(blocks ?? "");
671
- try {
672
- const out = await agent.send(text, { quiet: true });
673
- send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn", content: [{ type: "text", text: out }] } });
674
- } catch (e) {
675
- send({ jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: e instanceof Error ? e.message : String(e) } });
676
- }
677
- } else if (msg.id != null) send({ jsonrpc: "2.0", id: msg.id, result: {} });
678
- }
679
- });
680
- await new Promise(() => {});
681
- return;
682
- }
683
- if (sub === "share") {
684
- const arg = process.argv[3];
685
- const metas = list();
686
- const meta = arg ? metas.find((m) => m.file.includes(arg) || m.title.toLowerCase().includes(arg.toLowerCase())) : metas[0];
687
- if (!meta) {
688
- console.error(arg ? `no session matching "${arg}"` : "no sessions yet");
689
- process.exit(1);
690
- }
691
- const messages = Session.open(meta.file).load() as Array<{ role?: string; content?: unknown }>;
692
- const html = renderTranscript(meta.title, messages);
693
- const port = Number(process.env.ADA_SHARE_PORT) || 8790;
694
- const { createServer } = await import("node:http");
695
- createServer((_req, res) => res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(html)).listen(port, () =>
696
- console.log(`\x1b[38;5;214m◆\x1b[0m session "${meta.title}" → http://localhost:${port} (local, read-only — Ctrl+C to stop)`),
697
- );
698
- await new Promise(() => {});
699
- return;
700
- }
701
- if (sub === "serve") {
702
- const trusted = isTrusted(process.cwd());
703
- const settings = loadSettings(trusted);
704
- await loadExtensions(trusted);
705
- registerSkillTool(loadSkills(trusted));
706
- await loadMcpServers(trusted);
707
- const client = makeClient();
708
- let model = (process.argv[3] && !process.argv[3].startsWith("--") ? process.argv[3] : "") || process.env.ADA_MODEL || settings.model || "";
709
- if (!model) {
710
- try {
711
- model = (await fetchModelIds(client))[0] ?? "";
712
- } catch {
713
- /* offline */
714
- }
715
- }
716
- const port = Number(process.env.ADA_HTTP_PORT) || 8788;
717
- const { createServer } = await import("node:http");
718
- createServer((req, res) => {
719
- if (req.method === "GET" && (req.url === "/health" || req.url === "/")) {
720
- res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, model }));
721
- return;
722
- }
723
- if (req.method === "POST" && req.url === "/v1/prompt") {
724
- let body = "";
725
- req.on("data", (c) => (body += c));
726
- req.on("end", async () => {
727
- try {
728
- const j = JSON.parse(body || "{}") as { text?: string; model?: string };
729
- const agent = new Agent({ client, model: j.model || model, session: Session.create(), onApprove: async (): Promise<ApprovalDecision> => "yes", autoApprove: true, project: trusted, compactAt: settings.compactAt });
730
- const text = await agent.send(String(j.text ?? ""), { quiet: true });
731
- res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ text, usage: agent.usageReport() }));
732
- } catch (e) {
733
- res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
734
- }
735
- });
736
- return;
737
- }
738
- res.writeHead(404).end();
739
- }).listen(port, () => console.log(`ada HTTP API on http://localhost:${port} · POST /v1/prompt {"text":"…"} · model ${model || "(none — set one)"}`));
740
- await new Promise(() => {}); // keep the process alive for the server
741
- return;
742
- }
743
- const flags = parseArgs(process.argv.slice(2));
744
- void prefetch(); // warm the models.dev catalog (pricing/limits) in the background
745
- let client = makeClient();
746
-
747
- if (flags.listModels) {
748
- await printModels(client);
749
- return;
750
- }
751
-
752
- const scoped = flags.models ?? [];
753
-
754
- // Headless RPC mode: newline-delimited JSON over stdio. One {"type":"prompt","text":…} per line in.
755
- if (flags.rpc) {
756
- const trusted = isTrusted(process.cwd());
757
- const settings = loadSettings(trusted);
758
- let rm = flags.model ?? process.env.ADA_MODEL ?? settings.model ?? scoped[0] ?? "";
759
- if (!rm) {
760
- try {
761
- rm = (await fetchModelIds(client))[0] ?? "";
762
- } catch {
763
- /* ignore */
764
- }
765
- }
766
- if (!rm) {
767
- process.stdout.write(`${JSON.stringify({ type: "error", error: "no model available" })}\n`);
768
- process.exit(1);
769
- }
770
- await loadExtensions(trusted);
771
- registerSkillTool(loadSkills(trusted));
772
- await loadMcpServers(trusted);
773
- const agent = new Agent({
774
- client,
775
- model: rm,
776
- session: Session.create(),
777
- onApprove: async (): Promise<ApprovalDecision> => "yes",
778
- autoApprove: true,
779
- reasoning: flags.reasoning ?? settings.reasoning,
780
- project: trusted,
781
- compactAt: settings.compactAt,
782
- });
783
- process.stdout.write(`${JSON.stringify({ type: "ready", model: rm })}\n`);
784
- for await (const line of createInterface({ input: stdin })) {
785
- const t = line.trim();
786
- if (!t) continue;
787
- let prompt = t;
788
- try {
789
- const obj = JSON.parse(t) as { text?: string; prompt?: string };
790
- prompt = obj.text ?? obj.prompt ?? "";
791
- } catch {
792
- /* treat the raw line as the prompt */
793
- }
794
- if (!prompt) continue;
795
- try {
796
- const text = await agent.send(prompt, { quiet: true });
797
- process.stdout.write(`${JSON.stringify({ type: "result", text, usage: agent.usageReport() })}\n`);
798
- } catch (e) {
799
- process.stdout.write(`${JSON.stringify({ type: "error", error: e instanceof Error ? e.message : String(e) })}\n`);
800
- }
801
- }
802
- return;
803
- }
804
-
805
- // Headless print mode: run one prompt non-interactively and exit.
806
- if (flags.print !== undefined) {
807
- const trusted = isTrusted(process.cwd());
808
- const settings = loadSettings(trusted);
809
- let pm = flags.model ?? process.env.ADA_MODEL ?? settings.model ?? scoped[0] ?? "";
810
- if (!pm) {
811
- try {
812
- pm = (await fetchModelIds(client))[0] ?? "";
813
- } catch {
814
- /* ignore */
815
- }
816
- }
817
- if (!pm) {
818
- console.error("No model available. Pass --model <id> or set ADA_MODEL.");
819
- process.exit(1);
820
- }
821
- const agent = new Agent({
822
- client,
823
- model: pm,
824
- session: Session.create(),
825
- onApprove: async (): Promise<ApprovalDecision> => "yes",
826
- autoApprove: true,
827
- reasoning: flags.reasoning ?? settings.reasoning,
828
- project: trusted,
829
- compactAt: settings.compactAt,
830
- });
831
- if (flags.strategy) agent.setStrategy(flags.strategy);
832
- const text = await agent.send(flags.print, { quiet: !!flags.json });
833
- if (flags.json) console.log(JSON.stringify({ model: pm, text, usage: agent.usageReport() }));
834
- return;
835
- }
836
-
837
- const rl = createInterface({ input: stdin, output: stdout });
838
- await printBanner();
839
- // While a turn runs we listen for raw keys (interrupt/steer); onApprove pauses this to read a line.
840
- let turn: { onData: (b: Buffer) => void } | null = null;
841
-
842
- const includeProject = await ensureTrust(rl);
843
- const settings = loadSettings(includeProject);
844
- const prompts = loadPrompts(includeProject);
845
- const kbInterrupt = settings.keybindings?.interrupt;
846
- const exts = await loadExtensions(includeProject);
847
- const skills = loadSkills(includeProject);
848
- registerSkillTool(skills);
849
- const mcp = await loadMcpServers(includeProject);
850
-
851
- client = await ensureAuth(rl, client); // always check login at startup; prompt if the backend says 401
852
-
853
- let session: Session;
854
- let history: Msg[] = [];
855
- if (flags.cont) {
856
- const s = Session.latest();
857
- if (s) {
858
- session = s;
859
- history = s.load() as unknown as Msg[];
860
- console.log(`Resuming ${s.file} (${history.length} messages)`);
861
- } else {
862
- console.log("No session to continue; starting fresh.");
863
- session = Session.create();
864
- }
865
- } else if (flags.resume) {
866
- const file = await pickSession(rl);
867
- if (file) {
868
- session = Session.open(file);
869
- history = session.load() as unknown as Msg[];
870
- console.log(`Resuming (${history.length} messages)`);
871
- } else {
872
- session = Session.create();
873
- }
874
- } else {
875
- session = Session.create();
876
- }
877
-
878
- let model = flags.model ?? process.env.ADA_MODEL ?? settings.model ?? scoped[0] ?? "";
879
- if (!model) {
880
- model = await pickModel(client, rl);
881
- if (!model) {
882
- rl.close();
883
- return;
884
- }
885
- }
886
-
887
- const autoApprove = !!flags.yolo || process.env.ADA_AUTO_APPROVE === "1" || !!settings.autoApprove;
888
- // Permission mode: ask = confirm each tool, plan = read-only (plan, don't run), auto = run freely.
889
- let mode = "ask" as PermMode; // `as` keeps the CFA type PermMode (it's mutated via setMode, a closure)
890
- let setMode = (_m: PermMode): void => {}; // reassigned once `agent` exists
891
- const onApprove: OnApprove = async (name, summary): Promise<ApprovalDecision> => {
892
- if (mode === "auto") return "yes";
893
- if (turn && stdin.isTTY) rawOff(rl, turn.onData); // detach the turn's raw key listener first
894
- try {
895
- const choice = await approvePrompt(rl, name, summary);
896
- if (choice === "auto") {
897
- setMode("auto");
898
- return "all";
899
- }
900
- if (choice === "plan") {
901
- setMode("plan");
902
- return "no";
903
- }
904
- return choice; // "yes" | "no"
905
- } finally {
906
- if (turn && stdin.isTTY) rawOn(rl, turn.onData);
907
- }
908
- };
909
-
910
- setAsker(async (question, options) => {
911
- if (turn && stdin.isTTY) rawOff(rl, turn.onData);
912
- try {
913
- // Multiple-choice arrow-key selector; free-text → a plain line.
914
- if (options?.length && stdin.isTTY) {
915
- const i = await select(rl, `\x1b[36m? ${question}\x1b[0m`, options);
916
- return i == null ? "" : options[i]!;
917
- }
918
- let prompt = `\x1b[36m? ${question}\x1b[0m`;
919
- if (options?.length) prompt += `\n${options.map((o, i) => ` ${i + 1}. ${o}`).join("\n")}\n› `;
920
- else prompt += " ";
921
- const ans = (await rl.question(prompt)).trim();
922
- if (options?.length) {
923
- const n = Number(ans);
924
- if (Number.isInteger(n) && n >= 1 && n <= options.length) return options[n - 1]!;
925
- }
926
- return ans;
927
- } finally {
928
- if (turn && stdin.isTTY) rawOn(rl, turn.onData);
929
- }
930
- });
931
-
932
- // Subagent: delegate an isolated subtask to a fresh ada agent (registered before the agent
933
- // snapshots its tool list, so it appears in the registry).
934
- registerTool({
935
- name: "spawn_agent",
936
- description: "Delegate a self-contained subtask to a fresh ada sub-agent; returns its final summary. Use for isolated research or a chunk of work handled independently.",
937
- parameters: {
938
- type: "object",
939
- properties: { task: { type: "string", description: "The subtask, with all the context the sub-agent needs." } },
940
- required: ["task"],
941
- additionalProperties: false,
942
- },
943
- needsApproval: false,
944
- async run(args) {
945
- const sub = new Agent({
946
- client,
947
- model,
948
- session: Session.create(),
949
- onApprove,
950
- autoApprove,
951
- reasoning: flags.reasoning ?? settings.reasoning,
952
- project: includeProject,
953
- compactAt: settings.compactAt,
954
- });
955
- try {
956
- const text = await sub.send(String(args.task ?? ""), { quiet: true });
957
- return { output: text || "(sub-agent returned no text)" };
958
- } catch (e) {
959
- return { output: String(e instanceof Error ? e.message : e), isError: true };
960
- }
961
- },
962
- });
963
-
964
- registerTool({
965
- name: "background_task",
966
- description: "Start a self-contained subtask in the background and return its job id immediately — don't wait for it. Use for long, independent work. The user checks results with /jobs.",
967
- parameters: {
968
- type: "object",
969
- properties: { task: { type: "string", description: "The subtask, with all the context the sub-agent needs." } },
970
- required: ["task"],
971
- additionalProperties: false,
972
- },
973
- needsApproval: false,
974
- async run(args) {
975
- const task = String(args.task ?? "");
976
- const id = startJob(task, async () => {
977
- const sub = new Agent({ client, model, session: Session.create(), onApprove, autoApprove: true, project: includeProject, compactAt: settings.compactAt });
978
- return sub.send(task, { quiet: true });
979
- });
980
- return { output: `Started background job ${id}. Check results with /jobs (don't wait on it).` };
981
- },
982
- });
983
-
984
- const agent = new Agent({
985
- client,
986
- model,
987
- session,
988
- onApprove,
989
- autoApprove,
990
- reasoning: flags.reasoning ?? settings.reasoning,
991
- project: includeProject,
992
- compactAt: settings.compactAt,
993
- history,
994
- });
995
- if (flags.strategy) agent.setStrategy(flags.strategy);
996
- if (flags.agent && !switchAgent(agent, flags.agent, settings)) console.error(`unknown agent: ${flags.agent} (configure in .ada/settings.json)`);
997
-
998
- setMode = (m: PermMode): void => {
999
- mode = m;
1000
- agent.setPlanMode(m === "plan");
1001
- agent.setAutoApprove(m === "auto");
1002
- };
1003
- setMode(autoApprove ? "auto" : "ask"); // apply the initial mode (e.g. --yolo → auto) consistently
1004
-
1005
- if (flags.tui && stdin.isTTY) {
1006
- rl.close(); // hand stdin to the TUI so readline doesn't echo keystrokes too
1007
- await runTui(agent, model);
1008
- return;
1009
- }
1010
-
1011
- console.log(`\nada model ${model} via ${BACKEND}`);
1012
- console.log("commands: /model [id] /models /next /reasoning [low|medium|high|off] /compact /context /exit");
1013
- console.log(" \x1b[1mmode:\x1b[0m /ask /plan /auto (or /mode to cycle) · /run /fork /tree /rewind /undo /todos /cost /image /paste");
1014
- if (prompts.size) console.log(`prompt templates: ${[...prompts.keys()].map((k) => `/${k}`).join(" ")}`);
1015
- if (exts.length) console.log(`extensions: ${exts.join(" ")}`);
1016
- if (skills.length) console.log(`skills: ${skills.map((s) => s.name).join(" ")}`);
1017
- if (mcp.length) console.log(`mcp: ${mcp.join(" ")}`);
1018
- console.log("\x1b[2mduring a turn: Esc/Ctrl+C = interrupt · type + Enter = steer\x1b[0m\n");
1019
-
1020
- const pendingImages: string[] = []; // images attached via /image or /paste, sent with the next message
1021
- for (;;) {
1022
- if (stdin.isTTY) {
1023
- const modeTag = mode === "plan" ? " · \x1b[33mplan\x1b[0m\x1b[2m" : mode === "auto" ? " · \x1b[31mauto\x1b[0m\x1b[2m" : " · ask";
1024
- process.stdout.write(`\x1b[2m${model}${modeTag} · ~${agent.contextTokens()} tok\x1b[0m\n`);
1025
- }
1026
- let line: string;
1027
- try {
1028
- line = (await rl.question("\x1b[38;5;214m›\x1b[0m ")).trim();
1029
- } catch {
1030
- break; // stdin closed (Ctrl+D / EOF)
1031
- }
1032
- if (!line) continue;
1033
- if (line === "/exit" || line === "/quit") break;
1034
- if (line === "/compact") {
1035
- try {
1036
- console.log(await agent.compactNow());
1037
- } catch (e) {
1038
- console.error(`[error] ${e instanceof Error ? e.message : e}`);
1039
- }
1040
- continue;
1041
- }
1042
- if (line === "/context") {
1043
- console.log(`~${agent.contextTokens()} est. tokens in context`);
1044
- continue;
1045
- }
1046
- if (line === "/tree") {
1047
- printTree(session.file);
1048
- continue;
1049
- }
1050
- if (line === "/fork") {
1051
- session = Session.open(agent.fork());
1052
- console.log(`\x1b[2mforked → new branch ${basename(session.file)}\x1b[0m`);
1053
- continue;
1054
- }
1055
- if (line === "/rewind") {
1056
- console.log(agent.rewind());
1057
- continue;
1058
- }
1059
- if (line === "/cost") {
1060
- console.log(agent.usageReport());
1061
- continue;
1062
- }
1063
- if (line === "/undo") {
1064
- console.log(undoAll());
1065
- continue;
1066
- }
1067
- if (line === "/snapshot") {
1068
- const t = snapshot();
1069
- console.log(t ? `\x1b[38;5;214m✓\x1b[0m snapshot saved (${t.slice(0, 8)}) — /restore to roll back the whole tree` : "snapshot failed (not a git repo?)");
1070
- continue;
1071
- }
1072
- if (line === "/restore") {
1073
- console.log(restoreSnapshot() ? "\x1b[38;5;214m✓\x1b[0m restored the working tree to the last snapshot" : "nothing to restore (take a /snapshot first)");
1074
- continue;
1075
- }
1076
- if (line === "/jobs") {
1077
- console.log(renderJobs());
1078
- continue;
1079
- }
1080
- if (line === "/todos") {
1081
- console.log(renderTodos());
1082
- continue;
1083
- }
1084
- if (line === "/ask" || line === "/auto" || line === "/plan" || line === "/mode") {
1085
- const next: PermMode = line === "/mode" ? (mode === "ask" ? "plan" : mode === "plan" ? "auto" : "ask") : (line.slice(1) as PermMode);
1086
- setMode(next);
1087
- const blurb = { ask: "confirm each tool before it runs", plan: "ada plans but won't edit — /run to execute", auto: "run tools without asking (destructive bash still confirms)" }[next];
1088
- console.log(`mode → \x1b[1m${next}\x1b[0m \x1b[2m(${blurb})\x1b[0m`);
1089
- continue;
1090
- }
1091
- if (line === "/run") {
1092
- if (mode !== "plan") {
1093
- console.log("not in plan mode.");
1094
- continue;
1095
- }
1096
- setMode("ask");
1097
- console.log("\x1b[2mplan approved executing…\x1b[0m");
1098
- line = "Proceed and implement the plan above.";
1099
- }
1100
- if (line === "/models") {
1101
- await printModels(client);
1102
- continue;
1103
- }
1104
- if (line === "/catalog" || line.startsWith("/catalog ")) {
1105
- console.log(catalogText(line.slice("/catalog".length).trim() || undefined));
1106
- continue;
1107
- }
1108
- if (line === "/model" || line.startsWith("/model ")) {
1109
- const id = line.slice("/model".length).trim();
1110
- if (id) {
1111
- agent.setModel(id);
1112
- model = id;
1113
- console.log(`model ${id}`);
1114
- } else {
1115
- console.log(`current model: ${model}`);
1116
- }
1117
- continue;
1118
- }
1119
- if (line === "/next") {
1120
- if (scoped.length) {
1121
- model = scoped[(scoped.indexOf(model) + 1) % scoped.length]!;
1122
- agent.setModel(model);
1123
- console.log(`model → ${model}`);
1124
- } else {
1125
- console.log("no --models scope set (start with --models a,b,c)");
1126
- }
1127
- continue;
1128
- }
1129
- if (line === "/reasoning" || line.startsWith("/reasoning ")) {
1130
- const v = line.slice("/reasoning".length).trim();
1131
- if (v === "low" || v === "medium" || v === "high") {
1132
- agent.setReasoning(v);
1133
- console.log(`reasoning → ${v}`);
1134
- } else if (v === "off" || v === "none") {
1135
- agent.setReasoning(undefined);
1136
- console.log("reasoningoff");
1137
- } else {
1138
- console.log(`reasoning: ${agent.reasoning ?? "off"} (set: low | medium | high | off)`);
1139
- }
1140
- continue;
1141
- }
1142
- if (line === "/strategy" || line.startsWith("/strategy ")) {
1143
- const v = line.slice("/strategy".length).trim();
1144
- if (v) {
1145
- agent.setStrategy(v);
1146
- console.log(`strategy → ${v}`);
1147
- } else {
1148
- console.log(`strategy: ${agent.getStrategy()} (react | single | plan | multi | toolsmith)`);
1149
- }
1150
- continue;
1151
- }
1152
- if (line === "/agent" || line.startsWith("/agent ")) {
1153
- const name = line.slice("/agent".length).trim();
1154
- if (!name) console.log(`agents: ${Object.keys(settings.agents ?? {}).join(", ") || "(none — configure in .ada/settings.json)"}`);
1155
- else if (switchAgent(agent, name, settings)) console.log(`agent ${name}`);
1156
- else console.log(`unknown agent: ${name}`);
1157
- continue;
1158
- }
1159
- if (line === "/image" || line.startsWith("/image ")) {
1160
- const p = line.slice("/image".length).trim();
1161
- if (!p) {
1162
- console.log("usage: /image <path> (attaches an image to your next message)");
1163
- } else {
1164
- const img = loadImage(p);
1165
- if (!img) console.log(`could not read image: ${p} (need .png/.jpg/.gif/.webp/.bmp)`);
1166
- else {
1167
- pendingImages.push(img.dataUrl);
1168
- console.log(`\x1b[2m📎 ${img.name} (${Math.round(img.bytes / 1024)} KB) attached ${pendingImages.length} image(s) queued; now type your question\x1b[0m`);
1169
- }
1170
- }
1171
- continue;
1172
- }
1173
- if (line === "/paste") {
1174
- const clipImg = readClipboardImage();
1175
- if (clipImg) {
1176
- pendingImages.push(clipImg);
1177
- console.log(`\x1b[2m📎 image attached from clipboard — ${pendingImages.length} queued; now type your question\x1b[0m`);
1178
- continue;
1179
- }
1180
- const clip = readClipboard();
1181
- if (!clip) {
1182
- console.log("clipboard empty or unavailable");
1183
- continue;
1184
- }
1185
- console.log(`\x1b[2m(pasted ${clip.length} chars from clipboard)\x1b[0m`);
1186
- line = clip;
1187
- }
1188
- if (line.startsWith("/")) {
1189
- const cn = line.slice(1).split(/\s+/)[0]!;
1190
- const cmd = getCommands().get(cn);
1191
- if (cmd) {
1192
- try {
1193
- const out = await cmd.run(line.slice(1 + cn.length).trim());
1194
- if (out) console.log(out);
1195
- } catch (e) {
1196
- console.error(`[command ${cn}] ${e instanceof Error ? e.message : e}`);
1197
- }
1198
- continue;
1199
- }
1200
- }
1201
- let toSend = line;
1202
- if (line.startsWith("/")) {
1203
- const expanded = expandPrompt(prompts, line);
1204
- if (expanded === null) {
1205
- console.log(`unknown command: ${line.split(/\s+/)[0]} (chat without the leading /, or add .ada/prompts/<name>.md)`);
1206
- continue;
1207
- }
1208
- toSend = expanded;
1209
- }
1210
- const abort = new AbortController();
1211
- const steer: string[] = [];
1212
- let lineBuf = "";
1213
- const onData = (buf: Buffer): void => {
1214
- const s = buf.toString("utf8");
1215
- if (s === "\x03" || s === "\x1b" || (kbInterrupt !== undefined && s === kbInterrupt)) {
1216
- abort.abort(); // Ctrl+C / Esc / configured key → interrupt this turn
1217
- return;
1218
- }
1219
- if (s.startsWith("\x1b")) return; // ignore other escape sequences (arrow keys, etc.)
1220
- for (const ch of s) {
1221
- if (ch === "\r" || ch === "\n") {
1222
- const m = lineBuf.trim();
1223
- lineBuf = "";
1224
- if (m) {
1225
- steer.push(m);
1226
- process.stdout.write(`\x1b[2m ↳ queued (steers after this turn): ${m}\x1b[0m\n`);
1227
- }
1228
- } else if (ch === "\x7f") {
1229
- lineBuf = lineBuf.slice(0, -1);
1230
- } else if (ch >= " ") {
1231
- lineBuf += ch;
1232
- }
1233
- }
1234
- };
1235
- turn = { onData };
1236
- if (stdin.isTTY) rawOn(rl, onData);
1237
- const turnStart = Date.now();
1238
- track("turn", { model });
1239
- const imgs = pendingImages.length ? pendingImages.slice() : undefined;
1240
- pendingImages.length = 0;
1241
- process.stdout.write("\n\x1b[38;5;214m◆\x1b[0m \x1b[1mada\x1b[0m\n");
1242
- try {
1243
- await agent.send(toSend, { signal: abort.signal, steer, images: imgs });
1244
- if (!abort.signal.aborted && Date.now() - turnStart > 8000) notify("ada", "task complete");
1245
- } catch (e) {
1246
- track("error", { message: e instanceof Error ? e.message : String(e) });
1247
- console.error(`\n[error] ${e instanceof Error ? e.message : e}`);
1248
- } finally {
1249
- if (stdin.isTTY) rawOff(rl, onData);
1250
- turn = null;
1251
- }
1252
- }
1253
- rl.close();
1254
- }
1255
-
1256
- main().then(
1257
- () => process.exit(0), // explicit exit: node-pty (bash) and stdin can keep the loop alive otherwise
1258
- (e) => {
1259
- console.error(e instanceof Error ? e.message : e);
1260
- process.exit(1);
1261
- },
1262
- );
1
+ // ada client REPL. Talks only to the ada backend.
2
+
3
+ import { createInterface } from "node:readline/promises";
4
+ import { spawnSync } from "node:child_process";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+ import { stdin, stdout } from "node:process";
9
+ import OpenAI from "openai";
10
+ import { Agent, type ApprovalDecision, type OnApprove } from "./agent.ts";
11
+ import { expandPrompt, loadPrompts } from "./prompts.ts";
12
+ import { Session, list, type SessionMeta } from "./session.ts";
13
+ import { deleteCredential, getCredential, listCredentials } from "../server/credentials.ts";
14
+ import { deviceLogin, oauthConfig } from "../server/oauth.ts";
15
+ import { addTrust, isTrusted, loadSettings, setActiveAgentPermissions, type Settings } from "./settings.ts";
16
+ import { getCommands, loadExtensions } from "./extensions.ts";
17
+ import { registerTool, setAsker } from "./tools.ts";
18
+ import { addRemoteSkill, loadSkills, registerSkillTool } from "./skills.ts";
19
+ import { addConnector, listConnectors, loadMcpServers, removeConnector } from "./mcp.ts";
20
+ import { addExtension, selfUpdate } from "./pkg.ts";
21
+ import { runTui } from "./tui-mode.ts";
22
+ import { loadImage } from "./image.ts";
23
+ import { notify, readClipboard, readClipboardImage } from "./platform.ts";
24
+ import { undoAll } from "./checkpoint.ts";
25
+ import { restore as restoreSnapshot, snapshot } from "./snapshot.ts";
26
+ import { catalogText, prefetch } from "./models-dev.ts";
27
+ import { ensureBackend } from "./autostart.ts";
28
+ import { renderJobs, startJob } from "./background.ts";
29
+ import { renderTodos } from "./todos.ts";
30
+ import { track } from "./telemetry.ts";
31
+
32
+ type Msg = OpenAI.Chat.Completions.ChatCompletionMessageParam;
33
+ type RL = ReturnType<typeof createInterface>;
34
+
35
+ const BACKEND = process.env.ADA_BACKEND_URL ?? "http://localhost:8787/v1";
36
+
37
+ /** A stored GitHub/Google login token, sent as the bearer so the backend can identify us. */
38
+ function identityToken(): string | undefined {
39
+ for (const p of ["github", "google"]) {
40
+ const c = getCredential(p);
41
+ if (c?.type === "oauth" && c.access) return c.access;
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ function clientKey(): string {
47
+ return process.env.ADA_CLIENT_KEY ?? identityToken() ?? "dev";
48
+ }
49
+
50
+ interface Flags {
51
+ model?: string;
52
+ listModels?: boolean;
53
+ cont?: boolean;
54
+ resume?: boolean;
55
+ yolo?: boolean;
56
+ print?: string;
57
+ reasoning?: "low" | "medium" | "high";
58
+ models?: string[];
59
+ json?: boolean;
60
+ rpc?: boolean;
61
+ tui?: boolean;
62
+ strategy?: string;
63
+ agent?: string;
64
+ }
65
+
66
+ function escapeHtml(s: string): string {
67
+ return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c] ?? c);
68
+ }
69
+
70
+ /** Render a session transcript as a self-contained HTML page (for `ada share`). */
71
+ function renderTranscript(title: string, messages: Array<{ role?: string; content?: unknown }>): string {
72
+ const body = messages
73
+ .map((m) => {
74
+ const c = m.content;
75
+ const text = typeof c === "string" ? c : Array.isArray(c) ? c.map((p) => (p as { text?: string }).text ?? "[image]").join("") : c == null ? "" : JSON.stringify(c);
76
+ if (!text.trim()) return "";
77
+ return `<div class="msg ${m.role ?? ""}"><div class="role">${escapeHtml(m.role ?? "")}</div><pre>${escapeHtml(text)}</pre></div>`;
78
+ })
79
+ .join("\n");
80
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)} · ada</title><style>
81
+ body{background:#0d0f12;color:#e6e9ee;font:14px/1.6 ui-sans-serif,system-ui,sans-serif;max-width:820px;margin:0 auto;padding:32px}
82
+ h1{color:#ffaf00;font-size:18px}.msg{margin:16px 0;border-left:3px solid #262b33;padding-left:14px}
83
+ .msg.user{border-color:#ffaf00}.msg.assistant{border-color:#3fb950}.msg.tool{border-color:#82aaff}
84
+ .role{font:600 11px ui-monospace,monospace;color:#9aa3af;text-transform:uppercase;margin-bottom:4px}
85
+ pre{margin:0;white-space:pre-wrap;font:13px/1.6 ui-monospace,monospace;color:#c5cdd6}
86
+ </style></head><body><h1>◆ ${escapeHtml(title)}</h1>${body}</body></html>`;
87
+ }
88
+
89
+ /** Activate a named agent profile (prompt + permission rules) from settings. Returns false if unknown. */
90
+ function switchAgent(agent: Agent, name: string, settings: Settings): boolean {
91
+ const profile = settings.agents?.[name];
92
+ if (!profile) return false;
93
+ setActiveAgentPermissions(profile.permissions ?? null);
94
+ if (profile.prompt) agent.pushSystem(`You are now acting as the "${name}" agent. ${profile.prompt}`);
95
+ return true;
96
+ }
97
+
98
+ function parseArgs(argv: string[]): Flags {
99
+ const f: Flags = {};
100
+ for (let i = 0; i < argv.length; i++) {
101
+ const a = argv[i];
102
+ if (a === "--model") f.model = argv[++i];
103
+ else if (a === "--list-models") f.listModels = true;
104
+ else if (a === "--continue") f.cont = true;
105
+ else if (a === "--resume") f.resume = true;
106
+ else if (a === "--yolo") f.yolo = true;
107
+ else if (a === "-p" || a === "--print") f.print = argv[++i] ?? "";
108
+ else if (a === "--reasoning") {
109
+ const v = argv[++i];
110
+ if (v === "low" || v === "medium" || v === "high") f.reasoning = v;
111
+ } else if (a === "--models") {
112
+ f.models = (argv[++i] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
113
+ } else if (a === "--json") {
114
+ f.json = true;
115
+ } else if (a === "--rpc") {
116
+ f.rpc = true;
117
+ } else if (a === "--tui") {
118
+ f.tui = true;
119
+ } else if (a === "--strategy") {
120
+ f.strategy = argv[++i];
121
+ } else if (a === "--agent") {
122
+ f.agent = argv[++i];
123
+ }
124
+ }
125
+ return f;
126
+ }
127
+
128
+ function fuzzyPick(query: string, ids: string[]): string | null {
129
+ const q = query.toLowerCase();
130
+ const exact = ids.find((id) => id.toLowerCase() === q);
131
+ if (exact) return exact;
132
+ const subs = ids.filter((id) => id.toLowerCase().includes(q));
133
+ if (subs.length) return subs.sort((a, b) => a.length - b.length)[0]!;
134
+ return null;
135
+ }
136
+
137
+ async function fetchModelIds(client: OpenAI): Promise<string[]> {
138
+ const ids: string[] = [];
139
+ const res = await client.models.list();
140
+ for await (const m of res) ids.push(m.id);
141
+ return ids.sort();
142
+ }
143
+
144
+ function reportModelsError(e: unknown): void {
145
+ const msg = e instanceof Error ? e.message : String(e);
146
+ console.error(`Could not reach the ada backend at ${BACKEND}: ${msg}`);
147
+ console.error("Is the backend running? Start it in another terminal: npm run server");
148
+ }
149
+
150
+ async function printModels(client: OpenAI): Promise<void> {
151
+ try {
152
+ const ids = await fetchModelIds(client);
153
+ console.log(ids.join("\n"));
154
+ console.log(`\n${ids.length} models`);
155
+ } catch (e) {
156
+ reportModelsError(e);
157
+ }
158
+ }
159
+
160
+ async function pickModel(client: OpenAI, rl: RL): Promise<string> {
161
+ console.log("Fetching available models…");
162
+ let ids: string[] = [];
163
+ try {
164
+ ids = await fetchModelIds(client);
165
+ } catch (e) {
166
+ reportModelsError(e);
167
+ }
168
+ if (!ids.length) return (await rl.question("Enter a model id: ")).trim();
169
+ const shown = ids.slice(0, 40);
170
+ if (stdin.isTTY) {
171
+ const choice = await select(rl, "Select a model (↑/↓ · Enter · Esc to type an id):", shown);
172
+ if (choice !== null) return shown[choice]!;
173
+ }
174
+ shown.forEach((id, i) => console.log(`${String(i + 1).padStart(2)}. ${id}`));
175
+ if (ids.length > 40) console.log(`… and ${ids.length - 40} more (type an id directly)`);
176
+ const a = (await rl.question("pick # or type a model id: ")).trim();
177
+ const n = Number(a);
178
+ if (Number.isInteger(n) && n >= 1 && n <= ids.length) return ids[n - 1]!;
179
+ return fuzzyPick(a, ids) ?? a;
180
+ }
181
+
182
+ async function pickSession(rl: RL): Promise<string | null> {
183
+ const metas = list();
184
+ if (!metas.length) {
185
+ console.log("No saved sessions.");
186
+ return null;
187
+ }
188
+ const top = metas.slice(0, 20);
189
+ if (stdin.isTTY) {
190
+ const choice = await select(rl, "Resume which session? (↑/↓ · Enter · Esc to cancel):", top.map((m) => m.title));
191
+ if (choice !== null) return top[choice]?.file ?? null;
192
+ return null;
193
+ }
194
+ top.forEach((m, i) => console.log(`${String(i + 1).padStart(2)}. ${m.title}`));
195
+ const a = (await rl.question("resume #: ")).trim();
196
+ const idx = Number(a) - 1;
197
+ return top[idx]?.file ?? null;
198
+ }
199
+
200
+ function rawOn(rl: RL, onData: (b: Buffer) => void): void {
201
+ rl.pause();
202
+ if (stdin.isTTY) stdin.setRawMode(true);
203
+ stdin.on("data", onData);
204
+ stdin.resume();
205
+ }
206
+
207
+ function rawOff(rl: RL, onData: (b: Buffer) => void): void {
208
+ stdin.off("data", onData);
209
+ if (stdin.isTTY) stdin.setRawMode(false);
210
+ rl.resume();
211
+ }
212
+
213
+ /** Decode a raw stdin chunk to a logical key: arrows (or j/k/Tab), enter, esc, ctrl-c, or the literal char. */
214
+ function decodeKey(s: string): string {
215
+ if (s === "\x1b[A" || s === "k") return "up";
216
+ if (s === "\x1b[B" || s === "j" || s === "\t") return "down";
217
+ if (s === "\r" || s === "\n") return "enter";
218
+ if (s === "\x03") return "ctrl-c";
219
+ if (s === "\x1b") return "esc";
220
+ return s;
221
+ }
222
+
223
+ /**
224
+ * Read decoded keypresses in raw mode until a handler calls done(). Two robustness points the
225
+ * naive version got wrong: (1) a bare ESC may be the head of a split "\x1b[A" arrow sequence on
226
+ * Windows/slow ptys, so it's held ~50ms and re-joined with the next chunk before being treated as
227
+ * Esc; (2) teardown (listener removal + raw-mode restore + rl.resume) is guaranteed exactly once.
228
+ */
229
+ function readKeys(rl: RL, onKey: (key: string, done: () => void) => void): void {
230
+ let settled = false;
231
+ let escTimer: ReturnType<typeof setTimeout> | null = null;
232
+ const done = (): void => {
233
+ if (settled) return;
234
+ settled = true;
235
+ if (escTimer) clearTimeout(escTimer);
236
+ stdin.off("data", handler);
237
+ if (stdin.isTTY) stdin.setRawMode(false);
238
+ rl.resume();
239
+ };
240
+ const emit = (s: string): void => onKey(decodeKey(s), done);
241
+ const handler = (buf: Buffer): void => {
242
+ let s = buf.toString("utf8");
243
+ if (escTimer) {
244
+ clearTimeout(escTimer);
245
+ escTimer = null;
246
+ s = `\x1b${s}`; // re-join the ESC we were holding with this follow-up chunk (arrow keys)
247
+ }
248
+ if (s === "\x1b") {
249
+ escTimer = setTimeout(() => {
250
+ escTimer = null;
251
+ emit("\x1b");
252
+ }, 50);
253
+ return;
254
+ }
255
+ emit(s);
256
+ };
257
+ rl.pause();
258
+ if (stdin.isTTY) stdin.setRawMode(true);
259
+ stdin.on("data", handler);
260
+ stdin.resume();
261
+ }
262
+
263
+ /** Arrow-key list selector. Returns the chosen index, or null on Esc / non-TTY (caller falls back). */
264
+ async function select(rl: RL, title: string, items: string[]): Promise<number | null> {
265
+ if (!stdin.isTTY || !items.length) return null;
266
+ let idx = 0;
267
+ const draw = (first: boolean): void => {
268
+ if (!first) stdout.write(`\x1b[${items.length}A`); // jump back up to redraw in place
269
+ for (let i = 0; i < items.length; i++) {
270
+ stdout.write(i === idx ? `\x1b[2K\x1b[38;5;214m❯ ${items[i]}\x1b[0m\n` : `\x1b[2K ${items[i]}\n`);
271
+ }
272
+ };
273
+ stdout.write(`${title}\n`);
274
+ draw(true);
275
+ return await new Promise<number | null>((res) => {
276
+ readKeys(rl, (key, done) => {
277
+ if (key === "up") {
278
+ idx = (idx - 1 + items.length) % items.length;
279
+ draw(false);
280
+ } else if (key === "down") {
281
+ idx = (idx + 1) % items.length;
282
+ draw(false);
283
+ } else if (key === "enter") {
284
+ done();
285
+ res(idx);
286
+ } else if (key === "esc" || key === "ctrl-c") {
287
+ done();
288
+ res(null);
289
+ }
290
+ });
291
+ });
292
+ }
293
+
294
+ type PermMode = "ask" | "plan" | "auto";
295
+ type ApproveChoice = "yes" | "auto" | "plan" | "no";
296
+
297
+ /**
298
+ * Tool-approval prompt. A fixed single-key prompt (no scrolling redraw that fought the streaming
299
+ * transcript and glitched): it states in plain words what permission is being requested + the actual
300
+ * target, then reads one key — [y]es · [a]uto (run the rest without asking) · [p]lan (switch to plan
301
+ * mode, skip this) · [n]o/Esc. `summary` is "<permission phrase>\n<detail>" from the agent.
302
+ */
303
+ async function approvePrompt(rl: RL, name: string, summary: string): Promise<ApproveChoice> {
304
+ const nl = summary.indexOf("\n");
305
+ const risk = (nl >= 0 ? summary.slice(0, nl) : summary) || `run the ${name} tool`;
306
+ const detail = nl >= 0 ? summary.slice(nl + 1).trim() : "";
307
+ const danger = risk.startsWith("⚠");
308
+ if (!stdin.isTTY) {
309
+ const ans = (await rl.question(`\x1b[33m? ${risk} [y]es / [a]uto / [p]lan / [N]o \x1b[0m`)).trim().toLowerCase();
310
+ return ans[0] === "y" ? "yes" : ans[0] === "a" ? "auto" : ans[0] === "p" ? "plan" : "no";
311
+ }
312
+ const cols = (stdout.columns || 80) - 2;
313
+ const head = `${danger ? "\x1b[31m" : "\x1b[33m"}ada wants to ${risk.replace(/^⚠ /, "")}\x1b[0m`;
314
+ const det = detail ? ` \x1b[2m${detail.length > cols ? `${detail.slice(0, cols - 1)}…` : detail}\x1b[0m\n` : "";
315
+ stdout.write(`\n${danger ? "\x1b[31m⚠\x1b[0m " : ""}${head}\n${det}\x1b[2m[\x1b[0my\x1b[2m]es [\x1b[0ma\x1b[2m]uto [\x1b[0mp\x1b[2m]lan [\x1b[0mn\x1b[2m]o ›\x1b[0m `);
316
+ return await new Promise<ApproveChoice>((res) => {
317
+ readKeys(rl, (key, done) => {
318
+ const k = key.length === 1 ? key.toLowerCase() : key;
319
+ const val: ApproveChoice | null = k === "y" || key === "enter" ? "yes" : k === "a" ? "auto" : k === "p" ? "plan" : k === "n" || key === "esc" || key === "ctrl-c" ? "no" : null;
320
+ if (!val) return;
321
+ done();
322
+ stdout.write(`\r\x1b[2K${val === "no" ? "\x1b[31m✗\x1b[0m skipped" : `\x1b[32m✓\x1b[0m ${val === "auto" ? "auto (won't ask again)" : val === "plan" ? "→ plan mode" : "ran"}`}\n`);
323
+ res(val);
324
+ });
325
+ });
326
+ }
327
+
328
+ function printTree(currentFile: string): void {
329
+ const metas = list();
330
+ if (!metas.length) {
331
+ console.log("No sessions.");
332
+ return;
333
+ }
334
+ const children = new Map<string, SessionMeta[]>();
335
+ for (const m of metas) {
336
+ if (m.parent) {
337
+ const arr = children.get(m.parent) ?? [];
338
+ arr.push(m);
339
+ children.set(m.parent, arr);
340
+ }
341
+ }
342
+ const rec = (m: SessionMeta, depth: number): void => {
343
+ const mark = m.file === currentFile ? "\x1b[38;5;214m●\x1b[0m" : "○";
344
+ console.log(`${" ".repeat(depth)}${mark} ${basename(m.file)} \x1b[2m${m.title}\x1b[0m`);
345
+ for (const c of children.get(m.file) ?? []) rec(c, depth + 1);
346
+ };
347
+ for (const m of metas.filter((x) => !x.parent || !metas.some((y) => y.file === x.parent))) rec(m, 0);
348
+ }
349
+
350
+ function makeClient(): OpenAI {
351
+ return new OpenAI({ baseURL: BACKEND, apiKey: clientKey(), maxRetries: 0 });
352
+ }
353
+
354
+ /** Run the device flow for `provider`; returns true on success (token stored). */
355
+ async function loginFlow(provider: string): Promise<boolean> {
356
+ const cfg = oauthConfig(provider);
357
+ if (!cfg) {
358
+ console.log(`No OAuth config for ${provider}. Set ADA_OAUTH_${provider.toUpperCase()}_{CLIENT_ID,DEVICE_URL,TOKEN_URL}.`);
359
+ return false;
360
+ }
361
+ try {
362
+ await deviceLogin(provider, cfg, (s) => console.log(s));
363
+ return true;
364
+ } catch (e) {
365
+ console.error(`login failed: ${e instanceof Error ? e.message : e}`);
366
+ return false;
367
+ }
368
+ }
369
+
370
+ /** Startup login check: probe the backend; if it says 401, offer to sign in and rebuild the client. */
371
+ async function ensureAuth(rl: RL, client: OpenAI): Promise<OpenAI> {
372
+ let status: number;
373
+ try {
374
+ const r = await fetch(`${BACKEND}/whoami`, { headers: { authorization: `Bearer ${clientKey()}` } });
375
+ status = r.status;
376
+ } catch {
377
+ return client; // backend unreachable — the model fetch will report it
378
+ }
379
+ if (status !== 401) return client; // 200 = already authorized, or backend is open (dev)
380
+ const provider = ["github", "google"].find((p) => oauthConfig(p));
381
+ if (!provider) {
382
+ console.log("\x1b[33mthis backend requires login, but no OAuth provider is configured (set ADA_OAUTH_*).\x1b[0m");
383
+ return client;
384
+ }
385
+ const ans = (await rl.question(`\x1b[33mnot logged in sign in with ${provider}? [Y/n] \x1b[0m`)).trim().toLowerCase();
386
+ if (ans === "n" || ans === "no") return client;
387
+ return (await loginFlow(provider)) ? makeClient() : client;
388
+ }
389
+
390
+ async function authCommand(sub: string, provider?: string): Promise<void> {
391
+ if (!provider) {
392
+ console.error(`usage: ada ${sub} <provider>`);
393
+ console.log(listCredentials().length ? `logged in: ${listCredentials().join(", ")}` : "no stored credentials");
394
+ process.exit(1);
395
+ }
396
+ if (sub === "logout") {
397
+ await deleteCredential(provider);
398
+ console.log(`logged out ${provider}`);
399
+ return;
400
+ }
401
+ if (!(await loginFlow(provider))) process.exit(1);
402
+ }
403
+
404
+ /** Gate project-level files (.ada/prompts, AGENTS.md, project settings) behind explicit trust. */
405
+ async function ensureTrust(rl: RL): Promise<boolean> {
406
+ const cwd = process.cwd();
407
+ if (isTrusted(cwd)) return true;
408
+ if (!stdin.isTTY) return false; // headless: never load untrusted project files
409
+ const ans = (await rl.question(`Trust ${cwd} and load its .ada config (prompts, AGENTS.md, settings)? [y/N] `)).trim().toLowerCase();
410
+ if (ans === "y" || ans === "yes") {
411
+ addTrust(cwd);
412
+ return true;
413
+ }
414
+ return false;
415
+ }
416
+
417
+ // ANSI-Shadow "ada", rendered as a truecolor splash. █ = gradient body, box glyphs = drop shadow.
418
+ const ADA_ART = [
419
+ " █████╗ ██████╗ █████╗ ",
420
+ "██╔══██╗██╔══██╗██╔══██╗",
421
+ "███████║██║ ██║███████║",
422
+ "██╔══██║██║ ██║██╔══██║",
423
+ "██║ ██║██████╔╝██║ ██║",
424
+ "╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝",
425
+ ];
426
+ const GRADIENT: [number, number, number][] = [
427
+ [255, 214, 92], // gold
428
+ [255, 122, 41], // orange
429
+ [214, 51, 132], // magenta
430
+ ];
431
+
432
+ /** Interpolate the gradient stops at t∈[0,1]. */
433
+ function gradientAt(t: number): [number, number, number] {
434
+ const seg = Math.max(0, Math.min(1, t)) * (GRADIENT.length - 1);
435
+ const i = Math.min(Math.floor(seg), GRADIENT.length - 2);
436
+ const f = seg - i;
437
+ const [a, b] = [GRADIENT[i]!, GRADIENT[i + 1]!];
438
+ return [0, 1, 2].map((k) => Math.round(a[k]! + (b[k]! - a[k]!) * f)) as [number, number, number];
439
+ }
440
+
441
+ function adaVersion(): string {
442
+ try {
443
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
444
+ return (JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { version?: string }).version ?? "0.0.0";
445
+ } catch {
446
+ return "0.0.0";
447
+ }
448
+ }
449
+
450
+ const TAG = "a coding agent from zero";
451
+ const W = Math.max(...ADA_ART.map((l) => l.length));
452
+ const H = ADA_ART.length;
453
+ const EDGE = 6; // width of the bright leading edge of the light sweep
454
+
455
+ /** One logo row at light-sweep position `sweep`. Unlit ahead of the edge, white-hot at it, settled gradient behind. */
456
+ function logoRow(line: string, y: number, sweep: number): string {
457
+ let s = "\x1b[2K "; // clear line, then indent
458
+ [...line].forEach((ch, x) => {
459
+ if (ch === " ") return void (s += " ");
460
+ if (ch !== "█") return void (s += `\x1b[0m\x1b[38;2;92;72;82m${ch}`); // outline = drop shadow
461
+ if (x > sweep) return void (s += `\x1b[0m\x1b[38;2;70;55;62m█`); // not yet lit
462
+ const [r, g, b] = gradientAt((x / W + y / H) / 2);
463
+ const d = sweep - x;
464
+ if (d < EDGE) {
465
+ const t = 1 - d / EDGE; // 1 at the edge, fading to 0 as it settles
466
+ const mix = (c: number): number => Math.round(c + (255 - c) * t * 0.85);
467
+ return void (s += `\x1b[1m\x1b[38;2;${mix(r)};${mix(g)};${mix(b)}m█`);
468
+ }
469
+ return void (s += `\x1b[1m\x1b[38;2;${r};${g};${b}m█`);
470
+ });
471
+ return s + "\x1b[0m";
472
+ }
473
+
474
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
475
+
476
+ /** Startup splash: a ~400ms left-to-right light sweep over the logo on a TTY; static plain text otherwise. */
477
+ async function printBanner(): Promise<void> {
478
+ const fancy = stdout.isTTY === true && process.env.NO_COLOR === undefined;
479
+ if (!fancy) {
480
+ const body = ADA_ART.map((l) => ` ${l}`).join("\n");
481
+ stdout.write(`\n${body}\n ${TAG} v${adaVersion()}\n\n`);
482
+ return;
483
+ }
484
+ const frames = 18;
485
+ stdout.write("\x1b[?25l\n"); // hide cursor, leading blank line
486
+ try {
487
+ for (let f = 0; f <= frames; f++) {
488
+ const sweep = (f / frames) * (W + EDGE);
489
+ if (f > 0) stdout.write(`\x1b[${H}A`); // jump back up to redraw in place
490
+ stdout.write(`${ADA_ART.map((line, y) => logoRow(line, y, sweep)).join("\n")}\n`);
491
+ if (f < frames) await sleep(400 / frames);
492
+ }
493
+ } finally {
494
+ stdout.write("\x1b[?25h"); // always restore the cursor, even if interrupted
495
+ }
496
+ stdout.write(` \x1b[2m${TAG}\x1b[0m \x1b[38;2;214;51;132mv${adaVersion()}\x1b[0m\n\n`);
497
+ }
498
+
499
+ /** Subcommands that don't touch the backend — no point spawning a server for these. */
500
+ const NO_BACKEND = new Set(["mcp", "skill", "worktree", "wt", "catalog", "share"]);
501
+
502
+ async function main(): Promise<void> {
503
+ const sub = process.argv[2];
504
+ if (sub === "login" || sub === "logout") {
505
+ await authCommand(sub, process.argv[3]);
506
+ return;
507
+ }
508
+ // Auto-start ada-server if the configured backend isn't reachable (and it's local). New users
509
+ // shouldn't have to run two terminals; `ADA_BACKEND_URL` pointing at a remote skips this.
510
+ if (!NO_BACKEND.has(sub ?? "") && process.env.ADA_NO_AUTOSTART !== "1") {
511
+ const status = await ensureBackend(BACKEND);
512
+ if (status === "failed") {
513
+ console.error("ada-server failed to come up. Start it manually: `ada-server` (in another terminal).");
514
+ process.exit(1);
515
+ }
516
+ }
517
+ if (sub === "add") {
518
+ const spec = process.argv[3];
519
+ if (!spec) {
520
+ console.error("usage: ada add <git-url | npm-package>");
521
+ process.exit(1);
522
+ }
523
+ try {
524
+ addExtension(spec);
525
+ } catch (e) {
526
+ console.error(e instanceof Error ? e.message : e);
527
+ process.exit(1);
528
+ }
529
+ return;
530
+ }
531
+ if (sub === "update") {
532
+ selfUpdate();
533
+ return;
534
+ }
535
+ if (sub === "mcp") {
536
+ const action = process.argv[3] ?? "list";
537
+ const name = process.argv[4];
538
+ if (action === "list" || action === "ls") {
539
+ console.log("Connector catalog (● configured · ○ available):\n");
540
+ for (const c of listConnectors()) {
541
+ const dot = c.configured ? "\x1b[38;5;214m●\x1b[0m" : "○";
542
+ const env = c.needsEnv.length ? ` \x1b[2m(set: ${c.needsEnv.join(", ")})\x1b[0m` : "";
543
+ console.log(` ${dot} ${c.name.padEnd(14)} ${c.description}${env}`);
544
+ }
545
+ console.log("\n ada mcp add <name> · ada mcp remove <name>");
546
+ console.log(" custom server: edit .ada/mcp.json — a { command,args } (stdio) or { url } (http) entry");
547
+ return;
548
+ }
549
+ if (action === "add") {
550
+ if (!name) {
551
+ console.error("usage: ada mcp add <name>");
552
+ process.exit(1);
553
+ }
554
+ const r = addConnector(name);
555
+ if (!r.ok) {
556
+ console.error(r.error);
557
+ process.exit(1);
558
+ }
559
+ console.log(`\x1b[38;5;214m✓\x1b[0m added "${name}" to .ada/mcp.json`);
560
+ if (r.envVars.length) console.log(` set before use: ${r.envVars.join(", ")}`);
561
+ return;
562
+ }
563
+ if (action === "remove" || action === "rm") {
564
+ if (!name) {
565
+ console.error("usage: ada mcp remove <name>");
566
+ process.exit(1);
567
+ }
568
+ console.log(removeConnector(name) ? `removed "${name}" from .ada/mcp.json` : `"${name}" was not configured`);
569
+ return;
570
+ }
571
+ console.error("usage: ada mcp [list | add <name> | remove <name>]");
572
+ process.exit(1);
573
+ }
574
+ if (sub === "worktree" || sub === "wt") {
575
+ const action = process.argv[3] ?? "list";
576
+ const git = (...a: string[]): { status: number | null; out: string } => {
577
+ const r = spawnSync("git", a, { encoding: "utf8", cwd: process.cwd() });
578
+ return { status: r.status, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
579
+ };
580
+ if (action === "list" || action === "ls") {
581
+ const r = git("worktree", "list");
582
+ console.log(r.status === 0 ? r.out : "(not a git repo or no worktrees)");
583
+ return;
584
+ }
585
+ if (action === "add" || action === "new") {
586
+ const name = process.argv[4];
587
+ if (!name) {
588
+ console.error("usage: ada worktree add <name>");
589
+ process.exit(1);
590
+ }
591
+ const branch = `ada/${name}`;
592
+ const dir = resolve(process.cwd(), "..", `${basename(process.cwd())}-${name}`);
593
+ const r = git("worktree", "add", "-b", branch, dir);
594
+ if (r.status !== 0) {
595
+ console.error(r.out || "git worktree add failed");
596
+ process.exit(1);
597
+ }
598
+ console.log(`\x1b[38;5;214m✓\x1b[0m worktree ${dir}\n branch ${branch} — cd "${dir}" && ada`);
599
+ return;
600
+ }
601
+ if (action === "remove" || action === "rm") {
602
+ const name = process.argv[4];
603
+ if (!name) {
604
+ console.error("usage: ada worktree remove <name>");
605
+ process.exit(1);
606
+ }
607
+ const dir = resolve(process.cwd(), "..", `${basename(process.cwd())}-${name}`);
608
+ const r = git("worktree", "remove", dir);
609
+ console.log(r.status === 0 ? `removed ${dir}` : r.out);
610
+ return;
611
+ }
612
+ console.error("usage: ada worktree [list | add <name> | remove <name>]");
613
+ process.exit(1);
614
+ }
615
+ if (sub === "skill") {
616
+ const action = process.argv[3] ?? "list";
617
+ if (action === "add") {
618
+ const url = process.argv[4];
619
+ if (!url) {
620
+ console.error("usage: ada skill add <url> (a SKILL.md, or a JSON index of them)");
621
+ process.exit(1);
622
+ }
623
+ try {
624
+ const added = await addRemoteSkill(url);
625
+ console.log(added.length ? `\x1b[38;5;214m✓\x1b[0m installed: ${added.join(", ")} → ~/.ada/skills/` : "no skills found at that URL");
626
+ } catch (e) {
627
+ console.error(e instanceof Error ? e.message : e);
628
+ process.exit(1);
629
+ }
630
+ return;
631
+ }
632
+ if (action === "list" || action === "ls") {
633
+ for (const s of loadSkills(true)) console.log(` ${s.name.padEnd(22)} ${s.description}`);
634
+ return;
635
+ }
636
+ console.error("usage: ada skill [list | add <url>]");
637
+ process.exit(1);
638
+ }
639
+ if (sub === "catalog") {
640
+ // Offline model catalog (curated popular providers) — context limits + pricing, no backend/network.
641
+ console.log(catalogText(process.argv[3]));
642
+ return;
643
+ }
644
+ if (sub === "acp") {
645
+ // Minimal Agent Client Protocol bridge over stdio (JSON-RPC 2.0, newline-delimited). Scaffold:
646
+ // handles initialize + prompt so an ACP-aware editor can drive ada. Extend method names/framing
647
+ // to match your client's ACP version.
648
+ const trusted = isTrusted(process.cwd());
649
+ const settings = loadSettings(trusted);
650
+ await loadExtensions(trusted);
651
+ registerSkillTool(loadSkills(trusted));
652
+ await loadMcpServers(trusted);
653
+ const client = makeClient();
654
+ let model = process.env.ADA_MODEL || settings.model || "";
655
+ if (!model) {
656
+ try {
657
+ model = (await fetchModelIds(client))[0] ?? "";
658
+ } catch {
659
+ /* offline */
660
+ }
661
+ }
662
+ const agent = new Agent({ client, model, session: Session.create(), onApprove: async (): Promise<ApprovalDecision> => "yes", autoApprove: true, project: trusted, compactAt: settings.compactAt });
663
+ const send = (msg: object): void => void stdout.write(`${JSON.stringify(msg)}\n`);
664
+ let buf = "";
665
+ stdin.on("data", async (d) => {
666
+ buf += d.toString("utf8");
667
+ let nl: number;
668
+ while ((nl = buf.indexOf("\n")) >= 0) {
669
+ const line = buf.slice(0, nl).trim();
670
+ buf = buf.slice(nl + 1);
671
+ if (!line) continue;
672
+ let msg: { id?: number; method?: string; params?: Record<string, unknown> };
673
+ try {
674
+ msg = JSON.parse(line);
675
+ } catch {
676
+ continue;
677
+ }
678
+ if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1, agentCapabilities: { promptCapabilities: {} } } });
679
+ else if (msg.method === "session/new" || msg.method === "newSession") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "ada" } });
680
+ else if (msg.method === "session/prompt" || msg.method === "prompt") {
681
+ const p = msg.params ?? {};
682
+ const blocks = (p.prompt ?? p.text) as unknown;
683
+ const text = Array.isArray(blocks) ? blocks.map((b) => (b as { text?: string }).text ?? "").join("") : String(blocks ?? "");
684
+ try {
685
+ const out = await agent.send(text, { quiet: true });
686
+ send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn", content: [{ type: "text", text: out }] } });
687
+ } catch (e) {
688
+ send({ jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: e instanceof Error ? e.message : String(e) } });
689
+ }
690
+ } else if (msg.id != null) send({ jsonrpc: "2.0", id: msg.id, result: {} });
691
+ }
692
+ });
693
+ await new Promise(() => {});
694
+ return;
695
+ }
696
+ if (sub === "share") {
697
+ const arg = process.argv[3];
698
+ const metas = list();
699
+ const meta = arg ? metas.find((m) => m.file.includes(arg) || m.title.toLowerCase().includes(arg.toLowerCase())) : metas[0];
700
+ if (!meta) {
701
+ console.error(arg ? `no session matching "${arg}"` : "no sessions yet");
702
+ process.exit(1);
703
+ }
704
+ const messages = Session.open(meta.file).load() as Array<{ role?: string; content?: unknown }>;
705
+ const html = renderTranscript(meta.title, messages);
706
+ const port = Number(process.env.ADA_SHARE_PORT) || 8790;
707
+ const { createServer } = await import("node:http");
708
+ createServer((_req, res) => res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(html)).listen(port, () =>
709
+ console.log(`\x1b[38;5;214m◆\x1b[0m session "${meta.title}" → http://localhost:${port} (local, read-only — Ctrl+C to stop)`),
710
+ );
711
+ await new Promise(() => {});
712
+ return;
713
+ }
714
+ if (sub === "serve") {
715
+ const trusted = isTrusted(process.cwd());
716
+ const settings = loadSettings(trusted);
717
+ await loadExtensions(trusted);
718
+ registerSkillTool(loadSkills(trusted));
719
+ await loadMcpServers(trusted);
720
+ const client = makeClient();
721
+ let model = (process.argv[3] && !process.argv[3].startsWith("--") ? process.argv[3] : "") || process.env.ADA_MODEL || settings.model || "";
722
+ if (!model) {
723
+ try {
724
+ model = (await fetchModelIds(client))[0] ?? "";
725
+ } catch {
726
+ /* offline */
727
+ }
728
+ }
729
+ const port = Number(process.env.ADA_HTTP_PORT) || 8788;
730
+ const { createServer } = await import("node:http");
731
+ createServer((req, res) => {
732
+ if (req.method === "GET" && (req.url === "/health" || req.url === "/")) {
733
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, model }));
734
+ return;
735
+ }
736
+ if (req.method === "POST" && req.url === "/v1/prompt") {
737
+ let body = "";
738
+ req.on("data", (c) => (body += c));
739
+ req.on("end", async () => {
740
+ try {
741
+ const j = JSON.parse(body || "{}") as { text?: string; model?: string };
742
+ const agent = new Agent({ client, model: j.model || model, session: Session.create(), onApprove: async (): Promise<ApprovalDecision> => "yes", autoApprove: true, project: trusted, compactAt: settings.compactAt });
743
+ const text = await agent.send(String(j.text ?? ""), { quiet: true });
744
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ text, usage: agent.usageReport() }));
745
+ } catch (e) {
746
+ res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
747
+ }
748
+ });
749
+ return;
750
+ }
751
+ res.writeHead(404).end();
752
+ }).listen(port, () => console.log(`ada HTTP API on http://localhost:${port} · POST /v1/prompt {"text":"…"} · model ${model || "(none — set one)"}`));
753
+ await new Promise(() => {}); // keep the process alive for the server
754
+ return;
755
+ }
756
+ const flags = parseArgs(process.argv.slice(2));
757
+ void prefetch(); // warm the models.dev catalog (pricing/limits) in the background
758
+ let client = makeClient();
759
+
760
+ if (flags.listModels) {
761
+ await printModels(client);
762
+ return;
763
+ }
764
+
765
+ const scoped = flags.models ?? [];
766
+
767
+ // Headless RPC mode: newline-delimited JSON over stdio. One {"type":"prompt","text":…} per line in.
768
+ if (flags.rpc) {
769
+ const trusted = isTrusted(process.cwd());
770
+ const settings = loadSettings(trusted);
771
+ let rm = flags.model ?? process.env.ADA_MODEL ?? settings.model ?? scoped[0] ?? "";
772
+ if (!rm) {
773
+ try {
774
+ rm = (await fetchModelIds(client))[0] ?? "";
775
+ } catch {
776
+ /* ignore */
777
+ }
778
+ }
779
+ if (!rm) {
780
+ process.stdout.write(`${JSON.stringify({ type: "error", error: "no model available" })}\n`);
781
+ process.exit(1);
782
+ }
783
+ await loadExtensions(trusted);
784
+ registerSkillTool(loadSkills(trusted));
785
+ await loadMcpServers(trusted);
786
+ const agent = new Agent({
787
+ client,
788
+ model: rm,
789
+ session: Session.create(),
790
+ onApprove: async (): Promise<ApprovalDecision> => "yes",
791
+ autoApprove: true,
792
+ reasoning: flags.reasoning ?? settings.reasoning,
793
+ project: trusted,
794
+ compactAt: settings.compactAt,
795
+ });
796
+ process.stdout.write(`${JSON.stringify({ type: "ready", model: rm })}\n`);
797
+ for await (const line of createInterface({ input: stdin })) {
798
+ const t = line.trim();
799
+ if (!t) continue;
800
+ let prompt = t;
801
+ try {
802
+ const obj = JSON.parse(t) as { text?: string; prompt?: string };
803
+ prompt = obj.text ?? obj.prompt ?? "";
804
+ } catch {
805
+ /* treat the raw line as the prompt */
806
+ }
807
+ if (!prompt) continue;
808
+ try {
809
+ const text = await agent.send(prompt, { quiet: true });
810
+ process.stdout.write(`${JSON.stringify({ type: "result", text, usage: agent.usageReport() })}\n`);
811
+ } catch (e) {
812
+ process.stdout.write(`${JSON.stringify({ type: "error", error: e instanceof Error ? e.message : String(e) })}\n`);
813
+ }
814
+ }
815
+ return;
816
+ }
817
+
818
+ // Headless print mode: run one prompt non-interactively and exit.
819
+ if (flags.print !== undefined) {
820
+ const trusted = isTrusted(process.cwd());
821
+ const settings = loadSettings(trusted);
822
+ let pm = flags.model ?? process.env.ADA_MODEL ?? settings.model ?? scoped[0] ?? "";
823
+ if (!pm) {
824
+ try {
825
+ pm = (await fetchModelIds(client))[0] ?? "";
826
+ } catch {
827
+ /* ignore */
828
+ }
829
+ }
830
+ if (!pm) {
831
+ console.error("No model available. Pass --model <id> or set ADA_MODEL.");
832
+ process.exit(1);
833
+ }
834
+ const agent = new Agent({
835
+ client,
836
+ model: pm,
837
+ session: Session.create(),
838
+ onApprove: async (): Promise<ApprovalDecision> => "yes",
839
+ autoApprove: true,
840
+ reasoning: flags.reasoning ?? settings.reasoning,
841
+ project: trusted,
842
+ compactAt: settings.compactAt,
843
+ });
844
+ if (flags.strategy) agent.setStrategy(flags.strategy);
845
+ const text = await agent.send(flags.print, { quiet: !!flags.json });
846
+ if (flags.json) console.log(JSON.stringify({ model: pm, text, usage: agent.usageReport() }));
847
+ return;
848
+ }
849
+
850
+ const rl = createInterface({ input: stdin, output: stdout });
851
+ await printBanner();
852
+ // While a turn runs we listen for raw keys (interrupt/steer); onApprove pauses this to read a line.
853
+ let turn: { onData: (b: Buffer) => void } | null = null;
854
+
855
+ const includeProject = await ensureTrust(rl);
856
+ const settings = loadSettings(includeProject);
857
+ const prompts = loadPrompts(includeProject);
858
+ const kbInterrupt = settings.keybindings?.interrupt;
859
+ const exts = await loadExtensions(includeProject);
860
+ const skills = loadSkills(includeProject);
861
+ registerSkillTool(skills);
862
+ const mcp = await loadMcpServers(includeProject);
863
+
864
+ client = await ensureAuth(rl, client); // always check login at startup; prompt if the backend says 401
865
+
866
+ let session: Session;
867
+ let history: Msg[] = [];
868
+ if (flags.cont) {
869
+ const s = Session.latest();
870
+ if (s) {
871
+ session = s;
872
+ history = s.load() as unknown as Msg[];
873
+ console.log(`Resuming ${s.file} (${history.length} messages)`);
874
+ } else {
875
+ console.log("No session to continue; starting fresh.");
876
+ session = Session.create();
877
+ }
878
+ } else if (flags.resume) {
879
+ const file = await pickSession(rl);
880
+ if (file) {
881
+ session = Session.open(file);
882
+ history = session.load() as unknown as Msg[];
883
+ console.log(`Resuming (${history.length} messages)`);
884
+ } else {
885
+ session = Session.create();
886
+ }
887
+ } else {
888
+ session = Session.create();
889
+ }
890
+
891
+ let model = flags.model ?? process.env.ADA_MODEL ?? settings.model ?? scoped[0] ?? "";
892
+ if (!model) {
893
+ model = await pickModel(client, rl);
894
+ if (!model) {
895
+ rl.close();
896
+ return;
897
+ }
898
+ }
899
+
900
+ const autoApprove = !!flags.yolo || process.env.ADA_AUTO_APPROVE === "1" || !!settings.autoApprove;
901
+ // Permission mode: ask = confirm each tool, plan = read-only (plan, don't run), auto = run freely.
902
+ let mode = "ask" as PermMode; // `as` keeps the CFA type PermMode (it's mutated via setMode, a closure)
903
+ let setMode = (_m: PermMode): void => {}; // reassigned once `agent` exists
904
+ const onApprove: OnApprove = async (name, summary): Promise<ApprovalDecision> => {
905
+ if (mode === "auto") return "yes";
906
+ if (turn && stdin.isTTY) rawOff(rl, turn.onData); // detach the turn's raw key listener first
907
+ try {
908
+ const choice = await approvePrompt(rl, name, summary);
909
+ if (choice === "auto") {
910
+ setMode("auto");
911
+ return "all";
912
+ }
913
+ if (choice === "plan") {
914
+ setMode("plan");
915
+ return "no";
916
+ }
917
+ return choice; // "yes" | "no"
918
+ } finally {
919
+ if (turn && stdin.isTTY) rawOn(rl, turn.onData);
920
+ }
921
+ };
922
+
923
+ setAsker(async (question, options) => {
924
+ if (turn && stdin.isTTY) rawOff(rl, turn.onData);
925
+ try {
926
+ // Multiple-choice → arrow-key selector; free-text → a plain line.
927
+ if (options?.length && stdin.isTTY) {
928
+ const i = await select(rl, `\x1b[36m? ${question}\x1b[0m`, options);
929
+ return i == null ? "" : options[i]!;
930
+ }
931
+ let prompt = `\x1b[36m? ${question}\x1b[0m`;
932
+ if (options?.length) prompt += `\n${options.map((o, i) => ` ${i + 1}. ${o}`).join("\n")}\n› `;
933
+ else prompt += " ";
934
+ const ans = (await rl.question(prompt)).trim();
935
+ if (options?.length) {
936
+ const n = Number(ans);
937
+ if (Number.isInteger(n) && n >= 1 && n <= options.length) return options[n - 1]!;
938
+ }
939
+ return ans;
940
+ } finally {
941
+ if (turn && stdin.isTTY) rawOn(rl, turn.onData);
942
+ }
943
+ });
944
+
945
+ // Subagent: delegate an isolated subtask to a fresh ada agent (registered before the agent
946
+ // snapshots its tool list, so it appears in the registry).
947
+ registerTool({
948
+ name: "spawn_agent",
949
+ description: "Delegate a self-contained subtask to a fresh ada sub-agent; returns its final summary. Use for isolated research or a chunk of work handled independently.",
950
+ parameters: {
951
+ type: "object",
952
+ properties: { task: { type: "string", description: "The subtask, with all the context the sub-agent needs." } },
953
+ required: ["task"],
954
+ additionalProperties: false,
955
+ },
956
+ needsApproval: false,
957
+ async run(args) {
958
+ const sub = new Agent({
959
+ client,
960
+ model,
961
+ session: Session.create(),
962
+ onApprove,
963
+ autoApprove,
964
+ reasoning: flags.reasoning ?? settings.reasoning,
965
+ project: includeProject,
966
+ compactAt: settings.compactAt,
967
+ });
968
+ try {
969
+ const text = await sub.send(String(args.task ?? ""), { quiet: true });
970
+ return { output: text || "(sub-agent returned no text)" };
971
+ } catch (e) {
972
+ return { output: String(e instanceof Error ? e.message : e), isError: true };
973
+ }
974
+ },
975
+ });
976
+
977
+ registerTool({
978
+ name: "background_task",
979
+ description: "Start a self-contained subtask in the background and return its job id immediately — don't wait for it. Use for long, independent work. The user checks results with /jobs.",
980
+ parameters: {
981
+ type: "object",
982
+ properties: { task: { type: "string", description: "The subtask, with all the context the sub-agent needs." } },
983
+ required: ["task"],
984
+ additionalProperties: false,
985
+ },
986
+ needsApproval: false,
987
+ async run(args) {
988
+ const task = String(args.task ?? "");
989
+ const id = startJob(task, async () => {
990
+ const sub = new Agent({ client, model, session: Session.create(), onApprove, autoApprove: true, project: includeProject, compactAt: settings.compactAt });
991
+ return sub.send(task, { quiet: true });
992
+ });
993
+ return { output: `Started background job ${id}. Check results with /jobs (don't wait on it).` };
994
+ },
995
+ });
996
+
997
+ const agent = new Agent({
998
+ client,
999
+ model,
1000
+ session,
1001
+ onApprove,
1002
+ autoApprove,
1003
+ reasoning: flags.reasoning ?? settings.reasoning,
1004
+ project: includeProject,
1005
+ compactAt: settings.compactAt,
1006
+ history,
1007
+ });
1008
+ if (flags.strategy) agent.setStrategy(flags.strategy);
1009
+ if (flags.agent && !switchAgent(agent, flags.agent, settings)) console.error(`unknown agent: ${flags.agent} (configure in .ada/settings.json)`);
1010
+
1011
+ setMode = (m: PermMode): void => {
1012
+ mode = m;
1013
+ agent.setPlanMode(m === "plan");
1014
+ agent.setAutoApprove(m === "auto");
1015
+ };
1016
+ setMode(autoApprove ? "auto" : "ask"); // apply the initial mode (e.g. --yolo → auto) consistently
1017
+
1018
+ if (flags.tui && stdin.isTTY) {
1019
+ rl.close(); // hand stdin to the TUI so readline doesn't echo keystrokes too
1020
+ await runTui(agent, model);
1021
+ return;
1022
+ }
1023
+
1024
+ console.log(`\nada — model ${model} via ${BACKEND}`);
1025
+ console.log("commands: /model [id] /models /next /reasoning [low|medium|high|off] /compact /context /exit");
1026
+ console.log(" \x1b[1mmode:\x1b[0m /ask /plan /auto (or /mode to cycle) · /run /fork /tree /rewind /undo /todos /cost /image /paste");
1027
+ if (prompts.size) console.log(`prompt templates: ${[...prompts.keys()].map((k) => `/${k}`).join(" ")}`);
1028
+ if (exts.length) console.log(`extensions: ${exts.join(" ")}`);
1029
+ if (skills.length) console.log(`skills: ${skills.map((s) => s.name).join(" ")}`);
1030
+ if (mcp.length) console.log(`mcp: ${mcp.join(" ")}`);
1031
+ console.log("\x1b[2mduring a turn: Esc/Ctrl+C = interrupt · type + Enter = steer\x1b[0m\n");
1032
+
1033
+ const pendingImages: string[] = []; // images attached via /image or /paste, sent with the next message
1034
+ for (;;) {
1035
+ if (stdin.isTTY) {
1036
+ const modeTag = mode === "plan" ? " · \x1b[33mplan\x1b[0m\x1b[2m" : mode === "auto" ? " · \x1b[31mauto\x1b[0m\x1b[2m" : " · ask";
1037
+ process.stdout.write(`\x1b[2m${model}${modeTag} · ~${agent.contextTokens()} tok\x1b[0m\n`);
1038
+ }
1039
+ let line: string;
1040
+ try {
1041
+ line = (await rl.question("\x1b[38;5;214m›\x1b[0m ")).trim();
1042
+ } catch {
1043
+ break; // stdin closed (Ctrl+D / EOF)
1044
+ }
1045
+ if (!line) continue;
1046
+ if (line === "/exit" || line === "/quit") break;
1047
+ if (line === "/compact") {
1048
+ try {
1049
+ console.log(await agent.compactNow());
1050
+ } catch (e) {
1051
+ console.error(`[error] ${e instanceof Error ? e.message : e}`);
1052
+ }
1053
+ continue;
1054
+ }
1055
+ if (line === "/context") {
1056
+ console.log(`~${agent.contextTokens()} est. tokens in context`);
1057
+ continue;
1058
+ }
1059
+ if (line === "/tree") {
1060
+ printTree(session.file);
1061
+ continue;
1062
+ }
1063
+ if (line === "/fork") {
1064
+ session = Session.open(agent.fork());
1065
+ console.log(`\x1b[2mforked → new branch ${basename(session.file)}\x1b[0m`);
1066
+ continue;
1067
+ }
1068
+ if (line === "/rewind") {
1069
+ console.log(agent.rewind());
1070
+ continue;
1071
+ }
1072
+ if (line === "/cost") {
1073
+ console.log(agent.usageReport());
1074
+ continue;
1075
+ }
1076
+ if (line === "/undo") {
1077
+ console.log(undoAll());
1078
+ continue;
1079
+ }
1080
+ if (line === "/snapshot") {
1081
+ const t = snapshot();
1082
+ console.log(t ? `\x1b[38;5;214m✓\x1b[0m snapshot saved (${t.slice(0, 8)}) — /restore to roll back the whole tree` : "snapshot failed (not a git repo?)");
1083
+ continue;
1084
+ }
1085
+ if (line === "/restore") {
1086
+ console.log(restoreSnapshot() ? "\x1b[38;5;214m✓\x1b[0m restored the working tree to the last snapshot" : "nothing to restore (take a /snapshot first)");
1087
+ continue;
1088
+ }
1089
+ if (line === "/jobs") {
1090
+ console.log(renderJobs());
1091
+ continue;
1092
+ }
1093
+ if (line === "/todos") {
1094
+ console.log(renderTodos());
1095
+ continue;
1096
+ }
1097
+ if (line === "/ask" || line === "/auto" || line === "/plan" || line === "/mode") {
1098
+ const next: PermMode = line === "/mode" ? (mode === "ask" ? "plan" : mode === "plan" ? "auto" : "ask") : (line.slice(1) as PermMode);
1099
+ setMode(next);
1100
+ const blurb = { ask: "confirm each tool before it runs", plan: "ada plans but won't edit — /run to execute", auto: "run tools without asking (destructive bash still confirms)" }[next];
1101
+ console.log(`mode → \x1b[1m${next}\x1b[0m \x1b[2m(${blurb})\x1b[0m`);
1102
+ continue;
1103
+ }
1104
+ if (line === "/run") {
1105
+ if (mode !== "plan") {
1106
+ console.log("not in plan mode.");
1107
+ continue;
1108
+ }
1109
+ setMode("ask");
1110
+ console.log("\x1b[2mplan approved — executing…\x1b[0m");
1111
+ line = "Proceed and implement the plan above.";
1112
+ }
1113
+ if (line === "/models") {
1114
+ await printModels(client);
1115
+ continue;
1116
+ }
1117
+ if (line === "/catalog" || line.startsWith("/catalog ")) {
1118
+ console.log(catalogText(line.slice("/catalog".length).trim() || undefined));
1119
+ continue;
1120
+ }
1121
+ if (line === "/model" || line.startsWith("/model ")) {
1122
+ const id = line.slice("/model".length).trim();
1123
+ if (id) {
1124
+ agent.setModel(id);
1125
+ model = id;
1126
+ console.log(`model → ${id}`);
1127
+ } else {
1128
+ console.log(`current model: ${model}`);
1129
+ }
1130
+ continue;
1131
+ }
1132
+ if (line === "/next") {
1133
+ if (scoped.length) {
1134
+ model = scoped[(scoped.indexOf(model) + 1) % scoped.length]!;
1135
+ agent.setModel(model);
1136
+ console.log(`model${model}`);
1137
+ } else {
1138
+ console.log("no --models scope set (start with --models a,b,c)");
1139
+ }
1140
+ continue;
1141
+ }
1142
+ if (line === "/reasoning" || line.startsWith("/reasoning ")) {
1143
+ const v = line.slice("/reasoning".length).trim();
1144
+ if (v === "low" || v === "medium" || v === "high") {
1145
+ agent.setReasoning(v);
1146
+ console.log(`reasoning → ${v}`);
1147
+ } else if (v === "off" || v === "none") {
1148
+ agent.setReasoning(undefined);
1149
+ console.log("reasoning → off");
1150
+ } else {
1151
+ console.log(`reasoning: ${agent.reasoning ?? "off"} (set: low | medium | high | off)`);
1152
+ }
1153
+ continue;
1154
+ }
1155
+ if (line === "/strategy" || line.startsWith("/strategy ")) {
1156
+ const v = line.slice("/strategy".length).trim();
1157
+ if (v) {
1158
+ agent.setStrategy(v);
1159
+ console.log(`strategy ${v}`);
1160
+ } else {
1161
+ console.log(`strategy: ${agent.getStrategy()} (react | single | plan | multi | toolsmith)`);
1162
+ }
1163
+ continue;
1164
+ }
1165
+ if (line === "/agent" || line.startsWith("/agent ")) {
1166
+ const name = line.slice("/agent".length).trim();
1167
+ if (!name) console.log(`agents: ${Object.keys(settings.agents ?? {}).join(", ") || "(none — configure in .ada/settings.json)"}`);
1168
+ else if (switchAgent(agent, name, settings)) console.log(`agent ${name}`);
1169
+ else console.log(`unknown agent: ${name}`);
1170
+ continue;
1171
+ }
1172
+ if (line === "/image" || line.startsWith("/image ")) {
1173
+ const p = line.slice("/image".length).trim();
1174
+ if (!p) {
1175
+ console.log("usage: /image <path> (attaches an image to your next message)");
1176
+ } else {
1177
+ const img = loadImage(p);
1178
+ if (!img) console.log(`could not read image: ${p} (need .png/.jpg/.gif/.webp/.bmp)`);
1179
+ else {
1180
+ pendingImages.push(img.dataUrl);
1181
+ console.log(`\x1b[2m📎 ${img.name} (${Math.round(img.bytes / 1024)} KB) attached — ${pendingImages.length} image(s) queued; now type your question\x1b[0m`);
1182
+ }
1183
+ }
1184
+ continue;
1185
+ }
1186
+ if (line === "/paste") {
1187
+ const clipImg = readClipboardImage();
1188
+ if (clipImg) {
1189
+ pendingImages.push(clipImg);
1190
+ console.log(`\x1b[2m📎 image attached from clipboard — ${pendingImages.length} queued; now type your question\x1b[0m`);
1191
+ continue;
1192
+ }
1193
+ const clip = readClipboard();
1194
+ if (!clip) {
1195
+ console.log("clipboard empty or unavailable");
1196
+ continue;
1197
+ }
1198
+ console.log(`\x1b[2m(pasted ${clip.length} chars from clipboard)\x1b[0m`);
1199
+ line = clip;
1200
+ }
1201
+ if (line.startsWith("/")) {
1202
+ const cn = line.slice(1).split(/\s+/)[0]!;
1203
+ const cmd = getCommands().get(cn);
1204
+ if (cmd) {
1205
+ try {
1206
+ const out = await cmd.run(line.slice(1 + cn.length).trim());
1207
+ if (out) console.log(out);
1208
+ } catch (e) {
1209
+ console.error(`[command ${cn}] ${e instanceof Error ? e.message : e}`);
1210
+ }
1211
+ continue;
1212
+ }
1213
+ }
1214
+ let toSend = line;
1215
+ if (line.startsWith("/")) {
1216
+ const expanded = expandPrompt(prompts, line);
1217
+ if (expanded === null) {
1218
+ console.log(`unknown command: ${line.split(/\s+/)[0]} (chat without the leading /, or add .ada/prompts/<name>.md)`);
1219
+ continue;
1220
+ }
1221
+ toSend = expanded;
1222
+ }
1223
+ const abort = new AbortController();
1224
+ const steer: string[] = [];
1225
+ let lineBuf = "";
1226
+ const onData = (buf: Buffer): void => {
1227
+ const s = buf.toString("utf8");
1228
+ if (s === "\x03" || s === "\x1b" || (kbInterrupt !== undefined && s === kbInterrupt)) {
1229
+ abort.abort(); // Ctrl+C / Esc / configured key → interrupt this turn
1230
+ return;
1231
+ }
1232
+ if (s.startsWith("\x1b")) return; // ignore other escape sequences (arrow keys, etc.)
1233
+ for (const ch of s) {
1234
+ if (ch === "\r" || ch === "\n") {
1235
+ const m = lineBuf.trim();
1236
+ lineBuf = "";
1237
+ if (m) {
1238
+ steer.push(m);
1239
+ process.stdout.write(`\x1b[2m ↳ queued (steers after this turn): ${m}\x1b[0m\n`);
1240
+ }
1241
+ } else if (ch === "\x7f") {
1242
+ lineBuf = lineBuf.slice(0, -1);
1243
+ } else if (ch >= " ") {
1244
+ lineBuf += ch;
1245
+ }
1246
+ }
1247
+ };
1248
+ turn = { onData };
1249
+ if (stdin.isTTY) rawOn(rl, onData);
1250
+ const turnStart = Date.now();
1251
+ track("turn", { model });
1252
+ const imgs = pendingImages.length ? pendingImages.slice() : undefined;
1253
+ pendingImages.length = 0;
1254
+ process.stdout.write("\n\x1b[38;5;214m◆\x1b[0m \x1b[1mada\x1b[0m\n");
1255
+ try {
1256
+ await agent.send(toSend, { signal: abort.signal, steer, images: imgs });
1257
+ if (!abort.signal.aborted && Date.now() - turnStart > 8000) notify("ada", "task complete");
1258
+ } catch (e) {
1259
+ track("error", { message: e instanceof Error ? e.message : String(e) });
1260
+ console.error(`\n[error] ${e instanceof Error ? e.message : e}`);
1261
+ } finally {
1262
+ if (stdin.isTTY) rawOff(rl, onData);
1263
+ turn = null;
1264
+ }
1265
+ }
1266
+ rl.close();
1267
+ }
1268
+
1269
+ main().then(
1270
+ () => process.exit(0), // explicit exit: node-pty (bash) and stdin can keep the loop alive otherwise
1271
+ (e) => {
1272
+ console.error(e instanceof Error ? e.message : e);
1273
+ process.exit(1);
1274
+ },
1275
+ );