ada-agent 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -158,9 +158,10 @@ shows in the prompt line. In **ask** mode each gated tool prompts with what it w
158
158
  **auto** runs tools without asking (destructive `bash` still confirms). `--yolo` starts in **auto**.
159
159
 
160
160
  **Subcommands:** `ada mcp …` (connectors) · `ada skill add <url>` · `ada worktree add <name>` ·
161
- `ada catalog [provider]` (offline model/price catalog) · `ada serve` (HTTP API) · `ada share`
162
- (view a session) · `ada acp` (editor bridge). See
163
- [docs/integrations.md](docs/integrations.md) for the HTTP API, the typed SDK, and ACP.
161
+ `ada catalog [provider]` (offline model/price catalog) · `ada serve` (HTTP API one-shot **and**
162
+ Cursor-style streaming sessions for building ada into your own IDE) · `ada share` (view a session) ·
163
+ `ada acp` (editor bridge). See [docs/integrations.md](docs/integrations.md) for the HTTP API
164
+ (including live SSE sessions with approval gating), the typed SDK, and ACP.
164
165
 
165
166
  **Orchestration strategies** — the harness runs pluggable agent architectures (`--strategy <name>`
166
167
  or `/strategy`): `react` (default loop), `single` (one shot), `plan` (plan→execute), `multi`
@@ -9,20 +9,80 @@ build, an IdP) are described with what they'd take — they can't be "live" with
9
9
  ```bash
10
10
  ada serve # → http://localhost:8788 (ADA_HTTP_PORT to change)
11
11
  ```
12
- - `GET /health` → `{ ok, model }`
13
- - `POST /v1/prompt` `{ "text": "...", "model"?: "..." }` → `{ text, usage }` (runs a fresh agent turn)
12
+ - `GET /health` → `{ ok, model, sessions }`
13
+ - `POST /v1/prompt` `{ "text": "...", "model"?: "..." }` → `{ text, usage }` **one-shot**: a fresh
14
+ agent + fresh session per call, no memory between calls. Good for a "generate this" button, not a
15
+ chat panel.
16
+
17
+ ### Building a Cursor-style agent panel (an IDE integration)
18
+
19
+ For a real agent panel — persistent conversation, live streamed output, visible tool calls, and
20
+ edits that pause for **your own** approval UI instead of auto-running — use the **interactive
21
+ session** endpoints instead. This is the intended integration point for a custom IDE/editor, in any
22
+ language, over plain HTTP + Server-Sent Events:
23
+
24
+ ```
25
+ GET /v1/sessions → { sessions: [{ file, title, mtime, parent? }, …] }
26
+ POST /v1/sessions {"resume"?: "latest"|"<file>"} → { sessionId, model, file, resumed }
27
+ POST /v1/sessions/:id/prompt {"text":…, "images"?: [dataURL|https…]}
28
+ → SSE stream of events (see below), until "done"
29
+ (409 if a turn is already running on this session)
30
+ POST /v1/sessions/:id/approve {"id":…, "decision":"yes"|"all"|"no"}
31
+ POST /v1/sessions/:id/abort → cancel the running turn ("stop generating"); also
32
+ denies any approval it was parked on
33
+ POST /v1/sessions/:id/steer {"text":…} → queue a mid-turn user message (409 when idle)
34
+ PATCH /v1/sessions/:id {"mode":"ask"|"plan"|"auto"} → switch the permission mode live
35
+ DELETE /v1/sessions/:id → free the session (does not delete the transcript)
36
+ ```
37
+
38
+ The session holds one persistent `Agent` — history, model, and skill/tool state carry across every
39
+ `/prompt` call. Each `/prompt` call streams one event per SSE frame (`data: {...}\n\n`):
40
+
41
+ | `type` | Fields | Meaning |
42
+ |---|---|---|
43
+ | `text` | `delta` | A chunk of the assistant's reply |
44
+ | `tool_call` | `name`, `detail` | A tool is about to run |
45
+ | `tool_result` | `name`, `output`, `isError` | It finished |
46
+ | `approval_request` | `id`, `name`, `summary` | **Blocks** until you POST `.../approve` with this `id` — this is where your IDE shows its own "allow this edit?" UI |
47
+ | `done` | `text`, `usage` | Turn complete |
48
+ | `error` | `message` | The turn failed (e.g. upstream unreachable) |
49
+
50
+ Sessions default to `autoApprove: false` (unlike the one-shot `/v1/prompt`, which auto-approves
51
+ everything) — every gated tool call (file writes, destructive shell, …) fires `approval_request` and
52
+ waits for your response. If no `/prompt` stream is currently open when an approval is needed, it's
53
+ declined (fails closed, never runs silently).
54
+
55
+ **Resuming after a restart.** Sessions live in memory, so a `sessionId` doesn't survive `ada serve`
56
+ restarting — but every session's conversation is also persisted to an on-disk transcript
57
+ (`.ada/sessions/*.jsonl`), same as the CLI's own sessions. `GET /v1/sessions` lists them (newest
58
+ first); pass `resume: "latest"` or a specific `file` from that list to `POST /v1/sessions` to spin up
59
+ a **new** in-memory session seeded with that history — the conversation picks up right where it left
60
+ off. Verified live: kill `ada serve` mid-conversation, restart it, resume, and the model still recalls
61
+ what was said before the restart.
14
62
 
15
63
  ## Typed SDK — `src/sdk`
16
64
 
17
65
  ```ts
18
66
  import { createClient } from "ada-agent/sdk"; // in-repo: "./src/sdk/index.ts"
19
67
  const ada = createClient("http://localhost:8788");
20
- console.log(await ada.health());
68
+
69
+ // one-shot
21
70
  const { text } = await ada.prompt("list the files in this project");
71
+
72
+ // interactive — the IDE integration point
73
+ const session = await ada.session({ model: "claude-opus-4-8" });
74
+ await session.prompt("refactor foo.ts to use async/await", (e) => {
75
+ if (e.type === "text") process.stdout.write(e.delta);
76
+ if (e.type === "tool_call") console.log(`→ ${e.name} ${e.detail}`);
77
+ if (e.type === "approval_request") session.approve(e.id, myOwnConfirmUi(e) ? "yes" : "no");
78
+ if (e.type === "done") console.log("\n" + e.usage);
79
+ });
80
+ await session.close();
22
81
  ```
23
82
 
24
- It's a ~30-line `fetch` wrapper over the HTTP API above — if you'd rather not pull in the source,
25
- just POST to `/v1/prompt` directly.
83
+ It's a `fetch`-based wrapper (manual SSE parsing, no dependency) over the HTTP API above — if you'd
84
+ rather not pull in the source, or your IDE isn't Node/TypeScript, talk to the same endpoints directly
85
+ from any HTTP client that can read a chunked response (Java, Python, Rust, a browser, …).
26
86
 
27
87
  ## ACP bridge — `ada acp`
28
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ada-agent",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "A from-zero terminal coding agent with a Cursor-style routing backend, ~285 skills, MCP connectors, and ask/plan/auto modes",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,53 @@
1
+ // Pure helpers for ada's HTTP+SSE agent service (the session endpoints on `ada serve`). Kept
2
+ // separate from cli.ts's route wiring so the tricky bits — SSE framing, id generation, and
3
+ // approval correlation — are unit-testable offline, with no live model required.
4
+
5
+ import type { ApprovalDecision } from "./agent.ts";
6
+
7
+ /** Format one Server-Sent Events frame for a JSON-serializable event. */
8
+ export function sseFrame(event: unknown): string {
9
+ return `data: ${JSON.stringify(event)}\n\n`;
10
+ }
11
+
12
+ let seq = 0;
13
+ /** A short, process-unique id (session id, approval-request id, …). */
14
+ export function newId(prefix: string): string {
15
+ seq++;
16
+ return `${prefix}_${Date.now().toString(36)}${seq.toString(36)}`;
17
+ }
18
+
19
+ /**
20
+ * Correlates a mid-turn approval request with the IDE's later response. `wait()` is called from
21
+ * inside the Agent's onApprove callback (which is blocked on the returned promise); `settle()` is
22
+ * called from the POST .../approve handler once the IDE's user has decided.
23
+ */
24
+ export class ApprovalRegistry {
25
+ private pending = new Map<string, (d: ApprovalDecision) => void>();
26
+
27
+ wait(): { id: string; promise: Promise<ApprovalDecision> } {
28
+ const id = newId("appr");
29
+ const promise = new Promise<ApprovalDecision>((resolve) => this.pending.set(id, resolve));
30
+ return { id, promise };
31
+ }
32
+
33
+ /** Resolve a pending approval by id. False if the id is unknown (already settled, or bogus). */
34
+ settle(id: string, decision: ApprovalDecision): boolean {
35
+ const resolve = this.pending.get(id);
36
+ if (!resolve) return false;
37
+ this.pending.delete(id);
38
+ resolve(decision);
39
+ return true;
40
+ }
41
+
42
+ /** Deny every pending approval — an aborted turn must not stay parked on an unanswered prompt. */
43
+ abortAll(): number {
44
+ const n = this.pending.size;
45
+ for (const resolve of this.pending.values()) resolve("no");
46
+ this.pending.clear();
47
+ return n;
48
+ }
49
+
50
+ get size(): number {
51
+ return this.pending.size;
52
+ }
53
+ }
@@ -15,7 +15,14 @@ import { routeConfident, routeSkills } from "./skills.ts";
15
15
  import { Session } from "./session.ts";
16
16
 
17
17
  type Msg = OpenAI.Chat.Completions.ChatCompletionMessageParam;
18
- type SendCtrl = { signal?: AbortSignal; steer?: string[]; quiet?: boolean; images?: string[]; onReplyStart?: () => void };
18
+ /** Structured turn events for a caller (e.g. an IDE service) that wants more than plain text.
19
+ * When `onEvent` is set on SendCtrl, `send()` emits these instead of writing to stdout. */
20
+ export type AgentEvent =
21
+ | { type: "text"; delta: string }
22
+ | { type: "tool_call"; callId: string; name: string; detail: string }
23
+ | { type: "tool_result"; callId: string; name: string; output: string; isError: boolean }
24
+ | { type: "done"; text: string; usage: string };
25
+ type SendCtrl = { signal?: AbortSignal; steer?: string[]; quiet?: boolean; images?: string[]; onReplyStart?: () => void; onEvent?: (e: AgentEvent) => void };
19
26
  type ToolCall = { id: string; name: string; args: string };
20
27
  type StepResult = { content: string; toolCalls: ToolCall[] };
21
28
  type ToolDef = OpenAI.Chat.Completions.ChatCompletionTool;
@@ -493,6 +500,10 @@ export class Agent {
493
500
  async send(input: string, ctrl?: SendCtrl): Promise<string> {
494
501
  let replyStarted = false;
495
502
  const say = (s: string): void => {
503
+ if (ctrl?.onEvent) {
504
+ if (s.trim()) ctrl.onEvent({ type: "text", delta: s });
505
+ return;
506
+ }
496
507
  if (ctrl?.quiet) return;
497
508
  if (!replyStarted && s.trim()) {
498
509
  replyStarted = true;
@@ -539,6 +550,7 @@ export class Agent {
539
550
 
540
551
  const engine = this.makeEngine(ctrl, say, interrupted, drainSteer);
541
552
  await (ORCHESTRATORS[this.strategy] ?? reAct).run(engine);
553
+ ctrl?.onEvent?.({ type: "done", text: this.lastAssistant, usage: this.usageReport() });
542
554
  return this.lastAssistant;
543
555
  }
544
556
 
@@ -642,6 +654,7 @@ export class Agent {
642
654
  calls[tc.index] = entry;
643
655
  }
644
656
  if (tc.id) entry.id = tc.id;
657
+ else if (!entry.id) entry.id = `call_${tc.index}`; // some backends omit streamed ids — consumers key events on callId
645
658
  if (tc.function?.name) entry.name += tc.function.name;
646
659
  if (tc.function?.arguments) entry.args += tc.function.arguments;
647
660
  }
@@ -687,12 +700,14 @@ export class Agent {
687
700
  * append one tool message per call. */
688
701
  private async execTools(toolCalls: ToolCall[], ctrl: SendCtrl | undefined, say: (s: string) => void): Promise<void> {
689
702
  const signal = ctrl?.signal;
690
- const printCall = (name: string, args: Record<string, unknown>): void => {
703
+ const printCall = (callId: string, name: string, args: Record<string, unknown>): void => {
691
704
  const d = describeCall(name, args);
692
705
  const detail = d.detail ? ` ${d.detail.length > 100 ? `${d.detail.slice(0, 99)}…` : d.detail}` : "";
706
+ ctrl?.onEvent?.({ type: "tool_call", callId, name, detail: d.detail });
693
707
  say(`\n\x1b[2m• ${name}${detail}\x1b[0m\n`);
694
708
  };
695
- const printResult = (r: ToolResult): void => {
709
+ const printResult = (callId: string, name: string, r: ToolResult): void => {
710
+ ctrl?.onEvent?.({ type: "tool_result", callId, name, output: r.output, isError: !!r.isError });
696
711
  if (r.display) say(`${r.display}\n`);
697
712
  else if (r.isError) say(`\x1b[31m ${r.output.split("\n")[0]}\x1b[0m\n`);
698
713
  };
@@ -720,15 +735,15 @@ export class Agent {
720
735
  continue;
721
736
  }
722
737
  if (!tool) {
723
- printCall(c.name, args);
738
+ printCall(c.id, c.name, args);
724
739
  results[i] = { output: `Unknown tool: ${c.name}`, isError: true };
725
740
  continue;
726
741
  }
727
742
  const perm = permissionFor(c.name, summarize(args)); // configured allow/ask/deny rule, if any
728
743
  if (perm === "deny") {
729
- printCall(c.name, args);
744
+ printCall(c.id, c.name, args);
730
745
  results[i] = { output: "Denied by permission policy.", isError: true };
731
- printResult(results[i]!);
746
+ printResult(c.id, c.name, results[i]!);
732
747
  continue;
733
748
  }
734
749
  if (!tool.needsApproval && perm !== "ask") {
@@ -736,10 +751,10 @@ export class Agent {
736
751
  continue;
737
752
  }
738
753
  // gated tool (or a rule forces "ask") → sequential (so prompts and same-file writes don't race)
739
- printCall(c.name, args);
754
+ printCall(c.id, c.name, args);
740
755
  if (this.planMode && tool.needsApproval) {
741
756
  results[i] = { output: "Plan mode: not executing — finish the plan; the user approves with /run." };
742
- printResult(results[i]!);
757
+ printResult(c.id, c.name, results[i]!);
743
758
  continue;
744
759
  }
745
760
  const forceConfirm = c.name === "bash" && isDestructive(String(args.command ?? ""));
@@ -757,15 +772,15 @@ export class Agent {
757
772
  results[i] = await runTool(tool, c.name, args);
758
773
  }
759
774
  }
760
- printResult(results[i]!);
775
+ printResult(c.id, c.name, results[i]!);
761
776
  }
762
777
  await Promise.all(
763
778
  parallel.map(async (i) => {
764
779
  const c = toolCalls[i]!;
765
780
  const args = argsOf(c.args);
766
- printCall(c.name, args);
781
+ printCall(c.id, c.name, args);
767
782
  results[i] = await runTool(toolByName.get(c.name)!, c.name, args);
768
- printResult(results[i]!);
783
+ printResult(c.id, c.name, results[i]!);
769
784
  }),
770
785
  );
771
786
  for (let i = 0; i < toolCalls.length; i++) {
package/src/client/cli.ts CHANGED
@@ -7,7 +7,8 @@ import { readFileSync } from "node:fs";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { stdin, stdout } from "node:process";
9
9
  import OpenAI from "openai";
10
- import { Agent, type ApprovalDecision, type OnApprove } from "./agent.ts";
10
+ import { Agent, type AgentEvent, type ApprovalDecision, type OnApprove } from "./agent.ts";
11
+ import { ApprovalRegistry, newId, sseFrame } from "./agent-server.ts";
11
12
  import { expandPrompt, loadPrompts } from "./prompts.ts";
12
13
  import { Session, list, type SessionMeta } from "./session.ts";
13
14
  import { deleteCredential, getCredential, listCredentials } from "../server/credentials.ts";
@@ -501,6 +502,11 @@ const NO_BACKEND = new Set(["mcp", "skill", "worktree", "wt", "catalog", "share"
501
502
 
502
503
  async function main(): Promise<void> {
503
504
  const sub = process.argv[2];
505
+ if (sub === "--version" || sub === "-v" || sub === "version") {
506
+ // Before anything else — must not auto-start a backend just to print a version.
507
+ console.log(`ada ${adaVersion()}`);
508
+ return;
509
+ }
504
510
  if (sub === "login" || sub === "logout") {
505
511
  await authCommand(sub, process.argv[3]);
506
512
  return;
@@ -642,9 +648,10 @@ async function main(): Promise<void> {
642
648
  return;
643
649
  }
644
650
  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.
651
+ // Agent Client Protocol bridge over stdio (JSON-RPC 2.0, newline-delimited). Handles
652
+ // initialize / session/new / session/prompt, and streams session/update notifications
653
+ // (agent_message_chunk + tool_call/tool_call_update) while a turn runs — the shape ACP editors
654
+ // like Zed render live. Still experimental until exercised against a real ACP client.
648
655
  const trusted = isTrusted(process.cwd());
649
656
  const settings = loadSettings(trusted);
650
657
  await loadExtensions(trusted);
@@ -661,6 +668,9 @@ async function main(): Promise<void> {
661
668
  }
662
669
  const agent = new Agent({ client, model, session: Session.create(), onApprove: async (): Promise<ApprovalDecision> => "yes", autoApprove: true, project: trusted, compactAt: settings.compactAt });
663
670
  const send = (msg: object): void => void stdout.write(`${JSON.stringify(msg)}\n`);
671
+ const ACP_SESSION = newId("acp");
672
+ const update = (update: object): void => send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: ACP_SESSION, update } });
673
+ let acpCtrl: AbortController | null = null; // the in-flight prompt's abort handle (session/cancel)
664
674
  let buf = "";
665
675
  stdin.on("data", async (d) => {
666
676
  buf += d.toString("utf8");
@@ -676,16 +686,29 @@ async function main(): Promise<void> {
676
686
  continue;
677
687
  }
678
688
  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") {
689
+ else if (msg.method === "session/new" || msg.method === "newSession") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: ACP_SESSION } });
690
+ else if (msg.method === "session/cancel" || msg.method === "cancel") {
691
+ acpCtrl?.abort();
692
+ if (msg.id != null) send({ jsonrpc: "2.0", id: msg.id, result: {} });
693
+ } else if (msg.method === "session/prompt" || msg.method === "prompt") {
681
694
  const p = msg.params ?? {};
682
695
  const blocks = (p.prompt ?? p.text) as unknown;
683
696
  const text = Array.isArray(blocks) ? blocks.map((b) => (b as { text?: string }).text ?? "").join("") : String(blocks ?? "");
697
+ acpCtrl = new AbortController();
684
698
  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 }] } });
699
+ await agent.send(text, {
700
+ signal: acpCtrl.signal,
701
+ onEvent: (e: AgentEvent) => {
702
+ if (e.type === "text") update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: e.delta } });
703
+ else if (e.type === "tool_call") update({ sessionUpdate: "tool_call", toolCallId: e.callId, title: `${e.name} ${e.detail}`.trim(), status: "in_progress" });
704
+ else if (e.type === "tool_result") update({ sessionUpdate: "tool_call_update", toolCallId: e.callId, status: e.isError ? "failed" : "completed" });
705
+ },
706
+ });
707
+ send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: acpCtrl.signal.aborted ? "cancelled" : "end_turn" } });
687
708
  } catch (e) {
688
709
  send({ jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: e instanceof Error ? e.message : String(e) } });
710
+ } finally {
711
+ acpCtrl = null;
689
712
  }
690
713
  } else if (msg.id != null) send({ jsonrpc: "2.0", id: msg.id, result: {} });
691
714
  }
@@ -727,13 +750,54 @@ async function main(): Promise<void> {
727
750
  }
728
751
  }
729
752
  const port = Number(process.env.ADA_HTTP_PORT) || 8788;
753
+
754
+ // Interactive sessions — for driving ada like an IDE agent panel (live text/tool-call events,
755
+ // and edits pause for YOUR approval UI instead of auto-running). See docs/integrations.md.
756
+ interface AgentSession {
757
+ agent: Agent;
758
+ registry: ApprovalRegistry;
759
+ emit: ((frame: string) => void) | null; // set only while a /prompt request's SSE stream is open
760
+ file: string; // the on-disk transcript — survives an `ada serve` restart; resume with it
761
+ ctrl: AbortController | null; // set while a turn runs — doubles as the busy flag
762
+ steer: string[]; // queued mid-turn user messages, drained by the agent between steps
763
+ mode: "ask" | "plan" | "auto";
764
+ }
765
+ const sessions = new Map<string, AgentSession>();
766
+ // `resumeFile` reattaches to an existing on-disk transcript (e.g. after `ada serve` restarted) —
767
+ // its history replays into the new in-memory Agent so the conversation picks up where it left off.
768
+ const makeSession = (m: string, resumeFile?: string): { id: string; rec: AgentSession } => {
769
+ const session = resumeFile ? Session.open(resumeFile) : Session.create();
770
+ const history = resumeFile ? (session.load() as unknown as Msg[]) : undefined;
771
+ const rec: AgentSession = { agent: undefined as unknown as Agent, registry: new ApprovalRegistry(), emit: null, file: session.file, ctrl: null, steer: [], mode: "ask" };
772
+ rec.agent = new Agent({
773
+ client,
774
+ model: m,
775
+ session,
776
+ history,
777
+ project: trusted,
778
+ compactAt: settings.compactAt,
779
+ autoApprove: false,
780
+ onApprove: async (toolName, summary): Promise<ApprovalDecision> => {
781
+ if (!rec.emit) return "no"; // no open stream to ask through — fail closed, don't silently run
782
+ const { id, promise } = rec.registry.wait();
783
+ rec.emit(sseFrame({ type: "approval_request", id, name: toolName, summary }));
784
+ return promise;
785
+ },
786
+ });
787
+ const id = newId("sess");
788
+ sessions.set(id, rec);
789
+ return { id, rec };
790
+ };
791
+
730
792
  const { createServer } = await import("node:http");
731
793
  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 }));
794
+ const url = new URL(req.url ?? "/", "http://localhost");
795
+ if (req.method === "GET" && (url.pathname === "/health" || url.pathname === "/")) {
796
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, model, sessions: sessions.size }));
734
797
  return;
735
798
  }
736
- if (req.method === "POST" && req.url === "/v1/prompt") {
799
+ // One-shot, no memory between calls good for a "generate this" action, not a chat panel.
800
+ if (req.method === "POST" && url.pathname === "/v1/prompt") {
737
801
  let body = "";
738
802
  req.on("data", (c) => (body += c));
739
803
  req.on("end", async () => {
@@ -748,8 +812,207 @@ async function main(): Promise<void> {
748
812
  });
749
813
  return;
750
814
  }
815
+ // Interactive: persistent session, streamed events, approval round-trip.
816
+ // List on-disk transcripts (survive an `ada serve` restart) so an IDE can offer "resume".
817
+ if (req.method === "GET" && url.pathname === "/v1/sessions") {
818
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ sessions: list() }));
819
+ return;
820
+ }
821
+ if (req.method === "POST" && url.pathname === "/v1/sessions") {
822
+ let body = "";
823
+ req.on("data", (c) => (body += c));
824
+ req.on("end", () => {
825
+ let m = model;
826
+ let resume: string | undefined;
827
+ try {
828
+ const j = JSON.parse(body || "{}") as { model?: string; resume?: string };
829
+ m = j.model || model;
830
+ // "latest" picks the most recently modified transcript; otherwise resume expects one of
831
+ // the `file` values from GET /v1/sessions (a restarted `ada serve` has no memory of which
832
+ // in-memory sessionIds existed before, so the IDE re-resolves by transcript file instead).
833
+ if (j.resume === "latest") resume = list()[0]?.file;
834
+ else if (j.resume && list().some((s) => s.file === j.resume)) resume = j.resume;
835
+ } catch {
836
+ /* ignore, use default model + no resume */
837
+ }
838
+ if (resume) {
839
+ // A live in-memory session may still be appending to that transcript (e.g. the IDE lost
840
+ // its SSE stream and *assumed* a restart) — two Agents on one JSONL interleave twin
841
+ // conversations. Point the caller at the live session instead of forking the file.
842
+ const live = [...sessions.entries()].find(([, r]) => r.file === resume);
843
+ if (live) {
844
+ res.writeHead(409, { "content-type": "application/json" }).end(JSON.stringify({ error: "that transcript belongs to a live session — reuse it (or DELETE it first)", sessionId: live[0], busy: !!live[1].ctrl }));
845
+ return;
846
+ }
847
+ }
848
+ const { id, rec } = makeSession(m, resume);
849
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ sessionId: id, model: m, file: rec.file, resumed: !!resume }));
850
+ });
851
+ return;
852
+ }
853
+ const promptMatch = req.method === "POST" && url.pathname.match(/^\/v1\/sessions\/([^/]+)\/prompt$/);
854
+ if (promptMatch) {
855
+ const rec = sessions.get(promptMatch[1]!);
856
+ if (!rec) {
857
+ res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
858
+ return;
859
+ }
860
+ if (rec.ctrl) {
861
+ // One turn at a time per session — two interleaved prompts would corrupt one conversation.
862
+ res.writeHead(409, { "content-type": "application/json" }).end(JSON.stringify({ error: "a turn is already running on this session — abort it or wait for done" }));
863
+ return;
864
+ }
865
+ rec.ctrl = new AbortController(); // claim the session before any await, so a racing second prompt sees busy
866
+ // If the client dies MID-BODY (e.g. a dropped multi-MB image upload), 'end' never fires and
867
+ // the claim above would brick the session with a permanent 409 — release it on 'close'.
868
+ req.on("close", () => {
869
+ if (!req.complete) {
870
+ rec.ctrl = null;
871
+ rec.steer.length = 0;
872
+ }
873
+ });
874
+ let body = "";
875
+ req.on("data", (c) => (body += c));
876
+ req.on("end", async () => {
877
+ let text = "";
878
+ let images: string[] | undefined;
879
+ try {
880
+ const j = JSON.parse(body || "{}") as { text?: string; images?: string[] };
881
+ text = String(j.text ?? "");
882
+ if (Array.isArray(j.images) && j.images.length) images = j.images.map(String);
883
+ } catch {
884
+ /* empty prompt */
885
+ }
886
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
887
+ // If the client drops the SSE stream mid-turn (IDE reload/crash), abort the turn — else it
888
+ // runs headless, and in ask mode parks forever on an approval no one can see or answer.
889
+ res.on("close", () => {
890
+ if (!res.writableEnded) {
891
+ rec.ctrl?.abort();
892
+ rec.registry.abortAll();
893
+ }
894
+ });
895
+ rec.emit = (frame) => res.write(frame);
896
+ try {
897
+ await rec.agent.send(text, { signal: rec.ctrl!.signal, steer: rec.steer, images, onEvent: (e: AgentEvent) => res.write(sseFrame(e)) });
898
+ } catch (e) {
899
+ res.write(sseFrame({ type: "error", message: e instanceof Error ? e.message : String(e) }));
900
+ } finally {
901
+ rec.emit = null;
902
+ rec.ctrl = null;
903
+ rec.steer.length = 0;
904
+ res.end();
905
+ }
906
+ });
907
+ return;
908
+ }
909
+ const abortMatch = req.method === "POST" && url.pathname.match(/^\/v1\/sessions\/([^/]+)\/abort$/);
910
+ if (abortMatch) {
911
+ const rec = sessions.get(abortMatch[1]!);
912
+ if (!rec) {
913
+ res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
914
+ return;
915
+ }
916
+ const wasRunning = !!rec.ctrl;
917
+ rec.ctrl?.abort();
918
+ rec.registry.abortAll(); // a turn parked on an unanswered approval must not stay stuck
919
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, wasRunning }));
920
+ return;
921
+ }
922
+ const steerMatch = req.method === "POST" && url.pathname.match(/^\/v1\/sessions\/([^/]+)\/steer$/);
923
+ if (steerMatch) {
924
+ const rec = sessions.get(steerMatch[1]!);
925
+ if (!rec) {
926
+ res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
927
+ return;
928
+ }
929
+ let body = "";
930
+ req.on("data", (c) => (body += c));
931
+ req.on("end", () => {
932
+ let text = "";
933
+ try {
934
+ text = String((JSON.parse(body || "{}") as { text?: string }).text ?? "");
935
+ } catch {
936
+ /* stays empty */
937
+ }
938
+ if (!text || !rec.ctrl) {
939
+ // steering only makes sense mid-turn; when idle, just send the next prompt instead
940
+ res.writeHead(409, { "content-type": "application/json" }).end(JSON.stringify({ ok: false, error: rec.ctrl ? "empty text" : "no turn running — send a prompt instead" }));
941
+ return;
942
+ }
943
+ rec.steer.push(text);
944
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
945
+ });
946
+ return;
947
+ }
948
+ const modeMatch = req.method === "PATCH" && url.pathname.match(/^\/v1\/sessions\/([^/]+)$/);
949
+ if (modeMatch) {
950
+ const rec = sessions.get(modeMatch[1]!);
951
+ if (!rec) {
952
+ res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
953
+ return;
954
+ }
955
+ let body = "";
956
+ req.on("data", (c) => (body += c));
957
+ req.on("end", () => {
958
+ let mode: string | undefined;
959
+ try {
960
+ mode = (JSON.parse(body || "{}") as { mode?: string }).mode;
961
+ } catch {
962
+ /* stays undefined */
963
+ }
964
+ if (mode !== "ask" && mode !== "plan" && mode !== "auto") {
965
+ res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: 'mode must be "ask" | "plan" | "auto"' }));
966
+ return;
967
+ }
968
+ rec.mode = mode;
969
+ rec.agent.setPlanMode(mode === "plan");
970
+ rec.agent.setAutoApprove(mode === "auto");
971
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, mode }));
972
+ });
973
+ return;
974
+ }
975
+ const approveMatch = req.method === "POST" && url.pathname.match(/^\/v1\/sessions\/([^/]+)\/approve$/);
976
+ if (approveMatch) {
977
+ const rec = sessions.get(approveMatch[1]!);
978
+ if (!rec) {
979
+ res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
980
+ return;
981
+ }
982
+ let body = "";
983
+ req.on("data", (c) => (body += c));
984
+ req.on("end", () => {
985
+ let ok = false;
986
+ try {
987
+ const { id, decision } = JSON.parse(body || "{}") as { id?: string; decision?: ApprovalDecision };
988
+ if (id && decision) ok = rec.registry.settle(id, decision);
989
+ } catch {
990
+ /* ok stays false */
991
+ }
992
+ res.writeHead(ok ? 200 : 404, { "content-type": "application/json" }).end(JSON.stringify({ ok }));
993
+ });
994
+ return;
995
+ }
996
+ const delMatch = req.method === "DELETE" && url.pathname.match(/^\/v1\/sessions\/([^/]+)$/);
997
+ if (delMatch) {
998
+ const rec = sessions.get(delMatch[1]!);
999
+ rec?.ctrl?.abort(); // don't orphan a running turn
1000
+ rec?.registry.abortAll();
1001
+ const existed = sessions.delete(delMatch[1]!);
1002
+ res.writeHead(existed ? 200 : 404, { "content-type": "application/json" }).end(JSON.stringify({ ok: existed }));
1003
+ return;
1004
+ }
751
1005
  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)"}`));
1006
+ }).listen(port, () =>
1007
+ console.log(
1008
+ `ada HTTP API on http://localhost:${port} · model ${model || "(none — set one)"}\n` +
1009
+ ` one-shot: POST /v1/prompt {"text":"…"}\n` +
1010
+ ` interactive: POST /v1/sessions → {sessionId} (GET lists resumable transcripts)\n` +
1011
+ ` POST /v1/sessions/:id/prompt {"text":"…","images"?:[…]} (SSE: text/tool_call/tool_result/approval_request/done)\n` +
1012
+ ` POST /v1/sessions/:id/approve {"id":"…","decision":"yes"|"all"|"no"}\n` +
1013
+ ` POST /v1/sessions/:id/abort · /steer {"text":"…"} · PATCH /v1/sessions/:id {"mode":"ask"|"plan"|"auto"}`,
1014
+ ),
1015
+ );
753
1016
  await new Promise(() => {}); // keep the process alive for the server
754
1017
  return;
755
1018
  }
@@ -64,16 +64,28 @@ export function rankSkills(query: string, items: RankItem[], n = 5): { name: str
64
64
 
65
65
  /**
66
66
  * The single clearly-dominant skill for a query, or null when the match is weak/ambiguous.
67
- * Three gates, all required: a score floor, dominance over the runner-up, and — crucially — an
68
- * EXACT whole-token overlap with the skill NAME. That last gate is the precision guard against
69
- * lexical false positives: "make a powerpoint" prefix-matches "low-power" and even dominates, but
70
- * "powerpoint" never equals the name token "power", so it's correctly rejected.
67
+ * Four gates, all required:
68
+ * 1. a score floor;
69
+ * 2. dominance over the runner-up;
70
+ * 3. an EXACT whole-token overlap with the skill NAME the guard against prefix false positives
71
+ * ("make a powerpoint" prefix-matches "low-power" and even dominates, but "powerpoint" never
72
+ * equals the name token "power", so it's rejected);
73
+ * 4. query COVERAGE — strictly more than a third of the query's content tokens must EXACTLY match
74
+ * the skill's tokens. A conversational sentence that merely *contains* one skill-y keyword
75
+ * ("remember this: the secret word is X" → secret-scan, observed live) is about something else;
76
+ * a short task-like command ("describe the project" → project-overview) matches nearly all its
77
+ * tokens. Exact equality here on purpose — matches()'s 4-char prefixing is right for recall in
78
+ * rankSkills but inflates coverage ("remember" prefix-matches "remediate"), re-opening the leak.
71
79
  */
72
80
  export function confidentSkill(query: string, items: RankItem[]): string | null {
73
81
  const ranked = rankSkills(query, items, 2);
74
82
  const top = ranked[0];
75
83
  if (!top || top.score < 4) return null;
76
- if (ranked[1] && top.score < ranked[1].score * 1.3) return null; // reject ties/near-ties; the name-exact gate below is the real precision guard
77
- const q = new Set(tokenize(query));
78
- return tokenize(top.name).some((t) => q.has(t)) ? top.name : null;
84
+ if (ranked[1] && top.score < ranked[1].score * 1.3) return null; // reject ties/near-ties
85
+ const q = [...new Set(tokenize(query))];
86
+ if (!tokenize(top.name).some((t) => q.includes(t))) return null;
87
+ const item = items.find((it) => it.name === top.name);
88
+ const doc = new Set(tokenize(`${top.name} ${item?.description ?? ""} ${item?.category ?? ""}`));
89
+ const covered = q.filter((qt) => doc.has(qt)).length;
90
+ return covered / q.length > 1 / 3 ? top.name : null;
79
91
  }
package/src/sdk/index.ts CHANGED
@@ -1,21 +1,98 @@
1
- // Typed client SDK for the ada HTTP API (started with `ada serve`). Drive ada programmatically:
1
+ // Typed client SDK for the ada HTTP API (started with `ada serve`). Two ways to drive ada:
2
2
  //
3
- // import { createClient } from "ada/sdk";
3
+ // import { createClient } from "ada-agent/sdk"; // or "./src/sdk/index.ts" in-repo
4
4
  // const ada = createClient("http://localhost:8788");
5
+ //
6
+ // One-shot (no memory between calls — a "generate this" action, not a chat panel):
5
7
  // const { text } = await ada.prompt("list the files in this project");
8
+ //
9
+ // Interactive (a Cursor-style agent panel — persistent session, live events, edits pause for your
10
+ // own approval UI instead of auto-running):
11
+ // const session = await ada.session();
12
+ // await session.prompt("refactor foo.ts", (e) => {
13
+ // if (e.type === "text") process.stdout.write(e.delta);
14
+ // if (e.type === "tool_call") console.log(`→ ${e.name} ${e.detail}`);
15
+ // if (e.type === "approval_request") session.approve(e.id, myOwnConfirmUi(e.name, e.summary) ? "yes" : "no");
16
+ // });
6
17
 
7
18
  export interface PromptResult {
8
19
  text: string;
9
20
  usage?: string;
10
21
  }
11
22
 
23
+ /** One event from an interactive session's prompt stream. */
24
+ export type SessionEvent =
25
+ | { type: "text"; delta: string }
26
+ | { type: "tool_call"; callId: string; name: string; detail: string }
27
+ | { type: "tool_result"; callId: string; name: string; output: string; isError: boolean }
28
+ | { type: "approval_request"; id: string; name: string; summary: string }
29
+ | { type: "done"; text: string; usage: string }
30
+ | { type: "error"; message: string };
31
+
32
+ export interface AdaSession {
33
+ readonly id: string;
34
+ /** The on-disk transcript backing this session — survives an `ada serve` restart. Pass this (or
35
+ * `"latest"`) as `resume` to a later `session()` call to reattach after one. */
36
+ readonly file: string;
37
+ /** True if this session's history was seeded from an existing transcript. */
38
+ readonly resumed: boolean;
39
+ /** Send a prompt; `onEvent` fires for every event as the turn streams. Resolves once it's done.
40
+ * `images` are data: or https: URLs attached to the message. 409s if a turn is already running. */
41
+ prompt(text: string, onEvent: (e: SessionEvent) => void, opts?: { images?: string[] }): Promise<void>;
42
+ /** Answer a pending `approval_request` event by its id. */
43
+ approve(id: string, decision: "yes" | "all" | "no"): Promise<void>;
44
+ /** Cancel the currently-running turn (the "stop generating" button). Safe when idle. */
45
+ abort(): Promise<void>;
46
+ /** Queue a mid-turn user message — the agent folds it in between steps (the "steer" box). */
47
+ steer(text: string): Promise<void>;
48
+ /** Switch the session's permission mode: ask (gate every edit), plan (read-only), auto (run freely). */
49
+ setMode(mode: "ask" | "plan" | "auto"): Promise<void>;
50
+ /** Free the session's resources server-side. (Does not delete the on-disk transcript.) */
51
+ close(): Promise<void>;
52
+ }
53
+
54
+ /** One on-disk session transcript, as returned by `listSessions()`. */
55
+ export interface SessionMeta {
56
+ file: string;
57
+ mtime: number;
58
+ title: string;
59
+ parent?: string;
60
+ }
61
+
12
62
  export interface AdaClient {
13
- /** Send a prompt; runs a fresh agent turn server-side and returns its final text. */
63
+ /** One-shot: runs a fresh agent turn server-side (no memory between calls) and returns its final text. */
14
64
  prompt(text: string, opts?: { model?: string }): Promise<PromptResult>;
65
+ /**
66
+ * Start a persistent, streaming session — the Cursor-style integration point for an IDE.
67
+ * Pass `resume: "latest"` or a `file` from `listSessions()` to reattach an existing conversation
68
+ * (e.g. after `ada serve` restarted and the old in-memory sessionId is gone).
69
+ */
70
+ session(opts?: { model?: string; resume?: string }): Promise<AdaSession>;
71
+ /** On-disk session transcripts, newest first — for building a "resume which conversation?" picker. */
72
+ listSessions(): Promise<SessionMeta[]>;
15
73
  /** Server health + the default model. */
16
74
  health(): Promise<{ ok: boolean; model?: string }>;
17
75
  }
18
76
 
77
+ async function streamSse(res: Response, onEvent: (e: SessionEvent) => void): Promise<void> {
78
+ if (!res.ok || !res.body) throw new Error(`ada ${res.status}: ${await res.text().catch(() => res.statusText)}`);
79
+ const reader = res.body.getReader();
80
+ const decoder = new TextDecoder();
81
+ let buf = "";
82
+ for (;;) {
83
+ const { done, value } = await reader.read();
84
+ if (done) break;
85
+ buf += decoder.decode(value, { stream: true });
86
+ let idx: number;
87
+ while ((idx = buf.indexOf("\n\n")) >= 0) {
88
+ const frame = buf.slice(0, idx);
89
+ buf = buf.slice(idx + 2);
90
+ const line = frame.split("\n").find((l) => l.startsWith("data: "));
91
+ if (line) onEvent(JSON.parse(line.slice(6)) as SessionEvent);
92
+ }
93
+ }
94
+ }
95
+
19
96
  export function createClient(baseUrl = "http://localhost:8788"): AdaClient {
20
97
  const url = baseUrl.replace(/\/+$/, "");
21
98
  return {
@@ -28,6 +105,64 @@ export function createClient(baseUrl = "http://localhost:8788"): AdaClient {
28
105
  if (!res.ok) throw new Error(`ada ${res.status}: ${await res.text().catch(() => res.statusText)}`);
29
106
  return (await res.json()) as PromptResult;
30
107
  },
108
+ async session(opts) {
109
+ const res = await fetch(`${url}/v1/sessions`, {
110
+ method: "POST",
111
+ headers: { "content-type": "application/json" },
112
+ body: JSON.stringify({ model: opts?.model, resume: opts?.resume }),
113
+ });
114
+ if (!res.ok) throw new Error(`ada ${res.status}: ${await res.text().catch(() => res.statusText)}`);
115
+ const { sessionId, file, resumed } = (await res.json()) as { sessionId: string; file: string; resumed: boolean };
116
+ return {
117
+ id: sessionId,
118
+ file,
119
+ resumed,
120
+ async prompt(text, onEvent, opts) {
121
+ const r = await fetch(`${url}/v1/sessions/${sessionId}/prompt`, {
122
+ method: "POST",
123
+ headers: { "content-type": "application/json" },
124
+ body: JSON.stringify({ text, images: opts?.images }),
125
+ });
126
+ await streamSse(r, onEvent);
127
+ },
128
+ async approve(id, decision) {
129
+ const r = await fetch(`${url}/v1/sessions/${sessionId}/approve`, {
130
+ method: "POST",
131
+ headers: { "content-type": "application/json" },
132
+ body: JSON.stringify({ id, decision }),
133
+ });
134
+ if (!r.ok) throw new Error(`ada ${r.status}: could not settle approval ${id}`);
135
+ },
136
+ async abort() {
137
+ const r = await fetch(`${url}/v1/sessions/${sessionId}/abort`, { method: "POST" });
138
+ if (!r.ok) throw new Error(`ada ${r.status}: abort failed`);
139
+ },
140
+ async steer(text) {
141
+ const r = await fetch(`${url}/v1/sessions/${sessionId}/steer`, {
142
+ method: "POST",
143
+ headers: { "content-type": "application/json" },
144
+ body: JSON.stringify({ text }),
145
+ });
146
+ if (!r.ok) throw new Error(`ada ${r.status}: steer failed (is a turn running?)`);
147
+ },
148
+ async setMode(mode) {
149
+ const r = await fetch(`${url}/v1/sessions/${sessionId}`, {
150
+ method: "PATCH",
151
+ headers: { "content-type": "application/json" },
152
+ body: JSON.stringify({ mode }),
153
+ });
154
+ if (!r.ok) throw new Error(`ada ${r.status}: could not set mode`);
155
+ },
156
+ async close() {
157
+ await fetch(`${url}/v1/sessions/${sessionId}`, { method: "DELETE" });
158
+ },
159
+ };
160
+ },
161
+ async listSessions() {
162
+ const res = await fetch(`${url}/v1/sessions`);
163
+ if (!res.ok) throw new Error(`ada ${res.status}: ${await res.text().catch(() => res.statusText)}`);
164
+ return ((await res.json()) as { sessions: SessionMeta[] }).sessions;
165
+ },
31
166
  async health() {
32
167
  const res = await fetch(`${url}/health`);
33
168
  return (await res.json()) as { ok: boolean; model?: string };
package/src/selfcheck.ts CHANGED
@@ -11,7 +11,7 @@ import { expandPrompt } from "./client/prompts.ts";
11
11
  import { MarkdownStreamer, highlight, renderEditDiff } from "./client/render.ts";
12
12
  import { Session, list } from "./client/session.ts";
13
13
  import { loadSkills, registerSkillTool, routeConfident } from "./client/skills.ts";
14
- import { describeCall, parseTextToolCalls, permPhrase, readIntegrationDocs, soleIntegration, writeProjectSkills } from "./client/agent.ts";
14
+ import { Agent, describeCall, parseTextToolCalls, permPhrase, readIntegrationDocs, soleIntegration, writeProjectSkills } from "./client/agent.ts";
15
15
  import { userBar } from "./client/tui.ts";
16
16
  import { configuredServers, listConnectors, loadMcpServers } from "./client/mcp.ts";
17
17
  import { confidentSkill, rankSkills } from "./client/skill-router.ts";
@@ -117,6 +117,18 @@ async function main(): Promise<void> {
117
117
  rmSync(parent.file, { force: true });
118
118
  rmSync(branch.file, { force: true });
119
119
 
120
+ // --- resume: a session's on-disk history seeds a fresh Agent's context (no live model needed) ---
121
+ {
122
+ const s = Session.create();
123
+ s.append({ role: "user", content: "remember: the secret word is PINEAPPLE97" });
124
+ s.append({ role: "assistant", content: "got it" });
125
+ const history = s.load() as never[];
126
+ const bare = new Agent({ client: {} as never, model: "x", session: Session.create(), onApprove: async () => "yes" });
127
+ const resumed = new Agent({ client: {} as never, model: "x", session: s, onApprove: async () => "yes", history });
128
+ assert.ok(resumed.contextTokens() > bare.contextTokens(), "resuming with history seeds more context than a bare session");
129
+ rmSync(s.file, { force: true });
130
+ }
131
+
120
132
  // --- router prefix mapping ---
121
133
  assert.equal(route("gpt-4o"), "openai");
122
134
  assert.equal(route("o3-mini"), "openai");
@@ -281,6 +293,16 @@ async function main(): Promise<void> {
281
293
  assert.equal(route("anything-else"), "openrouter", "unmatched → openrouter");
282
294
  }
283
295
 
296
+ // --- `ada --version` prints the version and exits WITHOUT auto-starting a backend ---
297
+ {
298
+ const { spawnSync } = await import("node:child_process");
299
+ const { fileURLToPath } = await import("node:url");
300
+ const bin = fileURLToPath(new URL("../bin/ada.mjs", import.meta.url));
301
+ const r = spawnSync(process.execPath, [bin, "--version"], { encoding: "utf8", timeout: 30_000 });
302
+ assert.match(r.stdout, /^ada \d+\.\d+\.\d+/, `--version prints the version (got: ${JSON.stringify(r.stdout)} / ${JSON.stringify(r.stderr?.slice(0, 120))})`);
303
+ assert.ok(!/starting ada-server/.test(r.stderr ?? ""), "--version must not auto-start the backend");
304
+ }
305
+
284
306
  // --- autostart helpers: URL classification + /health derivation ---
285
307
  {
286
308
  const { isLocalBackend, healthUrl } = await import("./client/autostart.ts");
@@ -299,6 +321,31 @@ async function main(): Promise<void> {
299
321
  const jid = startJob("selfcheck job", async () => "job-done-ok");
300
322
  await new Promise((r) => setTimeout(r, 30));
301
323
  assert.ok(renderJobs().includes(jid) && /job-done-ok/.test(renderJobs()), "background job runs and reports its result");
324
+
325
+ // --- agent-server helpers: SSE framing, id uniqueness, approval correlation (no live model needed) ---
326
+ {
327
+ const { sseFrame, newId, ApprovalRegistry } = await import("./client/agent-server.ts");
328
+ assert.equal(sseFrame({ type: "done", text: "hi" }), 'data: {"type":"done","text":"hi"}\n\n', "sseFrame formats one data: frame");
329
+ const a = newId("sess");
330
+ const b = newId("sess");
331
+ assert.ok(a.startsWith("sess_") && a !== b, "newId is prefixed and unique");
332
+
333
+ const registry = new ApprovalRegistry();
334
+ const { id, promise } = registry.wait();
335
+ assert.equal(registry.size, 1, "wait() tracks one pending approval");
336
+ assert.ok(registry.settle(id, "yes"), "settle() resolves a known pending approval");
337
+ assert.equal(await promise, "yes", "the waiting promise resolves with the decision");
338
+ assert.equal(registry.size, 0, "settle() clears the pending entry");
339
+ assert.equal(registry.settle("nope", "no"), false, "settle() on an unknown id returns false");
340
+
341
+ // abortAll: an aborted turn must not stay parked on unanswered approvals
342
+ const a1 = registry.wait();
343
+ const a2 = registry.wait();
344
+ assert.equal(registry.abortAll(), 2, "abortAll reports how many were pending");
345
+ assert.equal(await a1.promise, "no", "aborted approvals resolve to 'no'");
346
+ assert.equal(await a2.promise, "no", "all of them");
347
+ assert.equal(registry.size, 0, "abortAll clears the registry");
348
+ }
302
349
  assert.equal((await toolByName.get("web_fetch")!.run({ url: "http://127.0.0.1/x" })).isError, true, "web_fetch blocks loopback (SSRF guard)");
303
350
 
304
351
  // --- destructive classifier: real dangers flagged; everyday redirects are not (2>/dev/null bug) ---
@@ -352,6 +399,18 @@ async function main(): Promise<void> {
352
399
  assert.equal(confidentSkill("draw an architecture diagram of this project", allSkills), "architecture-diagram", "confident: → architecture-diagram");
353
400
  assert.equal(confidentSkill("make a powerpoint about Q3 results", allSkills), null, "precision guard: 'powerpoint' must NOT auto-apply 'low-power'");
354
401
  assert.equal(confidentSkill("what is 2 + 2", allSkills), null, "ambiguous query → no auto-apply");
402
+ // Coverage gate — a long sentence merely CONTAINING a skill-y keyword must not auto-apply
403
+ // (observed live: this exact prompt pulled in secret-scan and derailed a small model).
404
+ assert.equal(
405
+ confidentSkill("Remember this fact for later: the secret word is PINEAPPLE97. Just confirm you will remember it, do not do anything else.", allSkills),
406
+ null,
407
+ "coverage gate: incidental 'secret' must NOT auto-apply secret-scan",
408
+ );
409
+ assert.equal(confidentSkill("I was talking to my friend about docker yesterday and she mentioned kubernetes", allSkills), null, "coverage gate: conversational mention of docker");
410
+ // Short rephrasings of the same incident — prefix-matching must not inflate coverage
411
+ // ("remember" prefix-matches "remediate"), and 1/3 exactly must not pass the strict gate.
412
+ assert.equal(confidentSkill("remember this: the secret word is X", allSkills), null, "coverage gate: short secret-word phrasing");
413
+ assert.equal(confidentSkill("remember the secret word", allSkills), null, "coverage gate: shortest secret-word phrasing");
355
414
  // LOADED was set by registerSkillTool(allSkills) above, so routeConfident/skillBody resolve a body.
356
415
  const applied = routeConfident("describe the project");
357
416
  assert.ok(applied?.name === "project-overview" && /purpose/i.test(applied.body), "routeConfident returns the skill body to inject");
@@ -23,9 +23,9 @@ export const PROVIDERS: Record<ProviderName, ProviderDef> = {
23
23
  baseURL: process.env.DASHSCOPE_BASE_URL ?? "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
24
24
  keyEnv: "DASHSCOPE_API_KEY",
25
25
  },
26
- // GitHub Copilot — OpenAI-compatible chat endpoint. COPILOT_API_KEY must be a Copilot *bearer*
27
- // token (exchanged from a GitHub OAuth token at /copilot_internal/v2/tokenthat exchange is not
28
- // implemented here; it needs a Copilot subscription). Required headers are added in the adapter.
26
+ // GitHub Copilot — OpenAI-compatible chat endpoint. Set COPILOT_API_KEY (a Copilot bearer you
27
+ // already have) OR COPILOT_GITHUB_TOKEN (a GitHub token with Copilot access the adapter runs
28
+ // the /copilot_internal/v2/token exchange and caches/refreshes the bearer; see copilot-token.ts).
29
29
  copilot: { baseURL: process.env.COPILOT_BASE_URL ?? "https://api.githubcopilot.com", keyEnv: "COPILOT_API_KEY" },
30
30
  // Cloudflare Workers AI / AI Gateway — OpenAI-compatible. Workers AI: set CLOUDFLARE_ACCOUNT_ID +
31
31
  // CLOUDFLARE_API_TOKEN (default URL). AI Gateway: point CLOUDFLARE_BASE_URL at the gateway URL.
@@ -57,6 +57,8 @@ export function providerKey(p: ProviderName): string | undefined {
57
57
 
58
58
  /** A provider is usable if it's keyless, its key env var is set, or a credential is stored. */
59
59
  export function isConfigured(p: ProviderName): boolean {
60
+ // Copilot has a second way in: a GitHub token the adapter exchanges for a bearer (copilot-token.ts).
61
+ if (p === "copilot" && process.env.COPILOT_GITHUB_TOKEN) return true;
60
62
  return PROVIDERS[p].keyEnv === "" || !!process.env[PROVIDERS[p].keyEnv] || !!getCredential(p);
61
63
  }
62
64
 
@@ -0,0 +1,35 @@
1
+ // GitHub Copilot bearer-token exchange. Copilot's endpoint doesn't take a GitHub token directly —
2
+ // you exchange one at /copilot_internal/v2/token for a short-lived bearer. Ways in, in order:
3
+ // COPILOT_API_KEY — you already have a bearer (pasted from another tool); used as-is.
4
+ // COPILOT_GITHUB_TOKEN — a GitHub OAuth token with Copilot access; exchanged + cached here,
5
+ // refreshed automatically before expiry.
6
+ // stored credential — whatever `ada login`-style credential storage holds for copilot.
7
+ // Untested against a live subscription (needs one) — the exchange shape matches the documented
8
+ // flow used by editor integrations; failures surface as a normal upstream error to the client.
9
+
10
+ import { providerKey } from "../config.ts";
11
+
12
+ let cached: { token: string; expiresAt: number } | null = null;
13
+
14
+ /** Drop the cached bearer (e.g. after an upstream 401 — revoked token or clock skew). */
15
+ export function invalidateCopilotBearer(): void {
16
+ cached = null;
17
+ }
18
+
19
+ /** The bearer to send to api.githubcopilot.com, or "" if no Copilot credentials are configured. */
20
+ export async function copilotBearer(): Promise<string> {
21
+ const direct = process.env.COPILOT_API_KEY;
22
+ if (direct) return direct;
23
+ const gh = process.env.COPILOT_GITHUB_TOKEN;
24
+ if (!gh) return providerKey("copilot") ?? ""; // stored credential, or unconfigured
25
+ if (cached && Date.now() < cached.expiresAt - 60_000) return cached.token;
26
+ const res = await fetch("https://api.github.com/copilot_internal/v2/token", {
27
+ headers: { authorization: `token ${gh}`, "user-agent": "ada" },
28
+ signal: AbortSignal.timeout(10_000),
29
+ });
30
+ if (!res.ok) throw new Error(`Copilot token exchange failed: HTTP ${res.status} — is COPILOT_GITHUB_TOKEN a GitHub token on an account with a Copilot subscription?`);
31
+ const j = (await res.json()) as { token?: string; expires_at?: number };
32
+ if (!j.token) throw new Error("Copilot token exchange returned no token");
33
+ cached = { token: j.token, expiresAt: (j.expires_at ?? Math.floor(Date.now() / 1000) + 600) * 1000 };
34
+ return cached.token;
35
+ }
@@ -4,17 +4,35 @@
4
4
  // that format, this adapter just swaps in the upstream base URL + key and streams the
5
5
  // response straight back — no translation needed.
6
6
 
7
+ import { readFileSync } from "node:fs";
7
8
  import type { ProviderName } from "../../shared/types.ts";
8
9
  import { PROVIDERS, providerKey } from "../config.ts";
9
10
  import { SSE_HEADERS } from "../sse.ts";
10
11
  import type { Adapter, ChatRequest } from "./adapter.ts";
12
+ import { copilotBearer, invalidateCopilotBearer } from "./copilot-token.ts";
11
13
 
12
- function authHeaders(provider: ProviderName): Record<string, string> {
14
+ const ADA_VERSION = (() => {
15
+ try {
16
+ return (JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8")) as { version?: string }).version ?? "0.0.0";
17
+ } catch {
18
+ return "0.0.0";
19
+ }
20
+ })();
21
+
22
+ async function authHeaders(provider: ProviderName): Promise<Record<string, string>> {
23
+ // GitHub Copilot: bearer comes from the token exchange (or COPILOT_API_KEY), plus the
24
+ // editor-identification headers its endpoint requires.
25
+ if (provider === "copilot") {
26
+ const bearer = await copilotBearer();
27
+ return {
28
+ ...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
29
+ "Copilot-Integration-Id": "vscode-chat",
30
+ "Editor-Version": `ada/${ADA_VERSION}`,
31
+ "Editor-Plugin-Version": `ada/${ADA_VERSION}`,
32
+ };
33
+ }
13
34
  const key = providerKey(provider);
14
- const base: Record<string, string> = key ? { authorization: `Bearer ${key}` } : {};
15
- // GitHub Copilot's endpoint requires these editor-identification headers.
16
- if (provider === "copilot") return { ...base, "Copilot-Integration-Id": "vscode-chat", "Editor-Version": "ada/0.0.1", "Editor-Plugin-Version": "ada/0.0.1" };
17
- return base;
35
+ return key ? { authorization: `Bearer ${key}` } : {};
18
36
  }
19
37
 
20
38
  export const openAICompatAdapter: Adapter = {
@@ -28,7 +46,7 @@ export const openAICompatAdapter: Adapter = {
28
46
  try {
29
47
  upstream = await fetch(`${def.baseURL}/chat/completions`, {
30
48
  method: "POST",
31
- headers: { "content-type": "application/json", ...authHeaders(provider) },
49
+ headers: { "content-type": "application/json", ...(await authHeaders(provider)) },
32
50
  body: JSON.stringify(outBody),
33
51
  });
34
52
  } catch (e) {
@@ -42,6 +60,8 @@ export const openAICompatAdapter: Adapter = {
42
60
  }
43
61
 
44
62
  if (!upstream.ok || !upstream.body) {
63
+ // A dead exchanged bearer (revoked / clock skew) would otherwise be reused until local expiry.
64
+ if (provider === "copilot" && upstream.status === 401) invalidateCopilotBearer();
45
65
  const text = await upstream.text().catch(() => "");
46
66
  res.writeHead(upstream.status || 502, { "content-type": "application/json" });
47
67
  res.end(text || JSON.stringify({ error: { message: `upstream error ${upstream.status}` } }));
@@ -67,7 +87,7 @@ export const openAICompatAdapter: Adapter = {
67
87
  async listModels(provider: ProviderName): Promise<string[]> {
68
88
  const def = PROVIDERS[provider];
69
89
  try {
70
- const r = await fetch(`${def.baseURL}/models`, { headers: authHeaders(provider) });
90
+ const r = await fetch(`${def.baseURL}/models`, { headers: await authHeaders(provider) });
71
91
  if (!r.ok) return [];
72
92
  const j = (await r.json()) as { data?: Array<{ id?: unknown }> };
73
93
  return (j.data ?? []).map((m) => m.id).filter((x): x is string => typeof x === "string");