ada-agent 0.3.0 → 0.4.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,74 @@ 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":…} → SSE stream of events (see below), until "done"
28
+ POST /v1/sessions/:id/approve {"id":…, "decision":"yes"|"all"|"no"}
29
+ DELETE /v1/sessions/:id → free the session (does not delete the transcript)
30
+ ```
31
+
32
+ The session holds one persistent `Agent` — history, model, and skill/tool state carry across every
33
+ `/prompt` call. Each `/prompt` call streams one event per SSE frame (`data: {...}\n\n`):
34
+
35
+ | `type` | Fields | Meaning |
36
+ |---|---|---|
37
+ | `text` | `delta` | A chunk of the assistant's reply |
38
+ | `tool_call` | `name`, `detail` | A tool is about to run |
39
+ | `tool_result` | `name`, `output`, `isError` | It finished |
40
+ | `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 |
41
+ | `done` | `text`, `usage` | Turn complete |
42
+ | `error` | `message` | The turn failed (e.g. upstream unreachable) |
43
+
44
+ Sessions default to `autoApprove: false` (unlike the one-shot `/v1/prompt`, which auto-approves
45
+ everything) — every gated tool call (file writes, destructive shell, …) fires `approval_request` and
46
+ waits for your response. If no `/prompt` stream is currently open when an approval is needed, it's
47
+ declined (fails closed, never runs silently).
48
+
49
+ **Resuming after a restart.** Sessions live in memory, so a `sessionId` doesn't survive `ada serve`
50
+ restarting — but every session's conversation is also persisted to an on-disk transcript
51
+ (`.ada/sessions/*.jsonl`), same as the CLI's own sessions. `GET /v1/sessions` lists them (newest
52
+ first); pass `resume: "latest"` or a specific `file` from that list to `POST /v1/sessions` to spin up
53
+ a **new** in-memory session seeded with that history — the conversation picks up right where it left
54
+ off. Verified live: kill `ada serve` mid-conversation, restart it, resume, and the model still recalls
55
+ what was said before the restart.
14
56
 
15
57
  ## Typed SDK — `src/sdk`
16
58
 
17
59
  ```ts
18
60
  import { createClient } from "ada-agent/sdk"; // in-repo: "./src/sdk/index.ts"
19
61
  const ada = createClient("http://localhost:8788");
20
- console.log(await ada.health());
62
+
63
+ // one-shot
21
64
  const { text } = await ada.prompt("list the files in this project");
65
+
66
+ // interactive — the IDE integration point
67
+ const session = await ada.session({ model: "claude-opus-4-8" });
68
+ await session.prompt("refactor foo.ts to use async/await", (e) => {
69
+ if (e.type === "text") process.stdout.write(e.delta);
70
+ if (e.type === "tool_call") console.log(`→ ${e.name} ${e.detail}`);
71
+ if (e.type === "approval_request") session.approve(e.id, myOwnConfirmUi(e) ? "yes" : "no");
72
+ if (e.type === "done") console.log("\n" + e.usage);
73
+ });
74
+ await session.close();
22
75
  ```
23
76
 
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.
77
+ It's a `fetch`-based wrapper (manual SSE parsing, no dependency) over the HTTP API above — if you'd
78
+ rather not pull in the source, or your IDE isn't Node/TypeScript, talk to the same endpoints directly
79
+ from any HTTP client that can read a chunked response (Java, Python, Rust, a browser, …).
26
80
 
27
81
  ## ACP bridge — `ada acp`
28
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ada-agent",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -29,6 +29,7 @@
29
29
  ],
30
30
  "bin": {
31
31
  "ada": "bin/ada.mjs",
32
+ "ada-agent": "bin/ada.mjs",
32
33
  "ada-server": "bin/ada-server.mjs"
33
34
  },
34
35
  "files": [
@@ -0,0 +1,45 @@
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
+ get size(): number {
43
+ return this.pending.size;
44
+ }
45
+ }
@@ -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"; name: string; detail: string }
23
+ | { type: "tool_result"; 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
 
@@ -690,9 +702,11 @@ export class Agent {
690
702
  const printCall = (name: string, args: Record<string, unknown>): void => {
691
703
  const d = describeCall(name, args);
692
704
  const detail = d.detail ? ` ${d.detail.length > 100 ? `${d.detail.slice(0, 99)}…` : d.detail}` : "";
705
+ ctrl?.onEvent?.({ type: "tool_call", name, detail: d.detail });
693
706
  say(`\n\x1b[2m• ${name}${detail}\x1b[0m\n`);
694
707
  };
695
- const printResult = (r: ToolResult): void => {
708
+ const printResult = (name: string, r: ToolResult): void => {
709
+ ctrl?.onEvent?.({ type: "tool_result", name, output: r.output, isError: !!r.isError });
696
710
  if (r.display) say(`${r.display}\n`);
697
711
  else if (r.isError) say(`\x1b[31m ${r.output.split("\n")[0]}\x1b[0m\n`);
698
712
  };
@@ -728,7 +742,7 @@ export class Agent {
728
742
  if (perm === "deny") {
729
743
  printCall(c.name, args);
730
744
  results[i] = { output: "Denied by permission policy.", isError: true };
731
- printResult(results[i]!);
745
+ printResult(c.name, results[i]!);
732
746
  continue;
733
747
  }
734
748
  if (!tool.needsApproval && perm !== "ask") {
@@ -739,7 +753,7 @@ export class Agent {
739
753
  printCall(c.name, args);
740
754
  if (this.planMode && tool.needsApproval) {
741
755
  results[i] = { output: "Plan mode: not executing — finish the plan; the user approves with /run." };
742
- printResult(results[i]!);
756
+ printResult(c.name, results[i]!);
743
757
  continue;
744
758
  }
745
759
  const forceConfirm = c.name === "bash" && isDestructive(String(args.command ?? ""));
@@ -757,7 +771,7 @@ export class Agent {
757
771
  results[i] = await runTool(tool, c.name, args);
758
772
  }
759
773
  }
760
- printResult(results[i]!);
774
+ printResult(c.name, results[i]!);
761
775
  }
762
776
  await Promise.all(
763
777
  parallel.map(async (i) => {
@@ -765,7 +779,7 @@ export class Agent {
765
779
  const args = argsOf(c.args);
766
780
  printCall(c.name, args);
767
781
  results[i] = await runTool(toolByName.get(c.name)!, c.name, args);
768
- printResult(results[i]!);
782
+ printResult(c.name, results[i]!);
769
783
  }),
770
784
  );
771
785
  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";
@@ -727,13 +728,51 @@ async function main(): Promise<void> {
727
728
  }
728
729
  }
729
730
  const port = Number(process.env.ADA_HTTP_PORT) || 8788;
731
+
732
+ // Interactive sessions — for driving ada like an IDE agent panel (live text/tool-call events,
733
+ // and edits pause for YOUR approval UI instead of auto-running). See docs/integrations.md.
734
+ interface AgentSession {
735
+ agent: Agent;
736
+ registry: ApprovalRegistry;
737
+ emit: ((frame: string) => void) | null; // set only while a /prompt request's SSE stream is open
738
+ file: string; // the on-disk transcript — survives an `ada serve` restart; resume with it
739
+ }
740
+ const sessions = new Map<string, AgentSession>();
741
+ // `resumeFile` reattaches to an existing on-disk transcript (e.g. after `ada serve` restarted) —
742
+ // its history replays into the new in-memory Agent so the conversation picks up where it left off.
743
+ const makeSession = (m: string, resumeFile?: string): { id: string; rec: AgentSession } => {
744
+ const session = resumeFile ? Session.open(resumeFile) : Session.create();
745
+ const history = resumeFile ? (session.load() as unknown as Msg[]) : undefined;
746
+ const rec: AgentSession = { agent: undefined as unknown as Agent, registry: new ApprovalRegistry(), emit: null, file: session.file };
747
+ rec.agent = new Agent({
748
+ client,
749
+ model: m,
750
+ session,
751
+ history,
752
+ project: trusted,
753
+ compactAt: settings.compactAt,
754
+ autoApprove: false,
755
+ onApprove: async (toolName, summary): Promise<ApprovalDecision> => {
756
+ if (!rec.emit) return "no"; // no open stream to ask through — fail closed, don't silently run
757
+ const { id, promise } = rec.registry.wait();
758
+ rec.emit(sseFrame({ type: "approval_request", id, name: toolName, summary }));
759
+ return promise;
760
+ },
761
+ });
762
+ const id = newId("sess");
763
+ sessions.set(id, rec);
764
+ return { id, rec };
765
+ };
766
+
730
767
  const { createServer } = await import("node:http");
731
768
  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 }));
769
+ const url = new URL(req.url ?? "/", "http://localhost");
770
+ if (req.method === "GET" && (url.pathname === "/health" || url.pathname === "/")) {
771
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, model, sessions: sessions.size }));
734
772
  return;
735
773
  }
736
- if (req.method === "POST" && req.url === "/v1/prompt") {
774
+ // One-shot, no memory between calls good for a "generate this" action, not a chat panel.
775
+ if (req.method === "POST" && url.pathname === "/v1/prompt") {
737
776
  let body = "";
738
777
  req.on("data", (c) => (body += c));
739
778
  req.on("end", async () => {
@@ -748,8 +787,100 @@ async function main(): Promise<void> {
748
787
  });
749
788
  return;
750
789
  }
790
+ // Interactive: persistent session, streamed events, approval round-trip.
791
+ // List on-disk transcripts (survive an `ada serve` restart) so an IDE can offer "resume".
792
+ if (req.method === "GET" && url.pathname === "/v1/sessions") {
793
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ sessions: list() }));
794
+ return;
795
+ }
796
+ if (req.method === "POST" && url.pathname === "/v1/sessions") {
797
+ let body = "";
798
+ req.on("data", (c) => (body += c));
799
+ req.on("end", () => {
800
+ let m = model;
801
+ let resume: string | undefined;
802
+ try {
803
+ const j = JSON.parse(body || "{}") as { model?: string; resume?: string };
804
+ m = j.model || model;
805
+ // "latest" picks the most recently modified transcript; otherwise resume expects one of
806
+ // the `file` values from GET /v1/sessions (a restarted `ada serve` has no memory of which
807
+ // in-memory sessionIds existed before, so the IDE re-resolves by transcript file instead).
808
+ if (j.resume === "latest") resume = list()[0]?.file;
809
+ else if (j.resume && list().some((s) => s.file === j.resume)) resume = j.resume;
810
+ } catch {
811
+ /* ignore, use default model + no resume */
812
+ }
813
+ const { id, rec } = makeSession(m, resume);
814
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ sessionId: id, model: m, file: rec.file, resumed: !!resume }));
815
+ });
816
+ return;
817
+ }
818
+ const promptMatch = req.method === "POST" && url.pathname.match(/^\/v1\/sessions\/([^/]+)\/prompt$/);
819
+ if (promptMatch) {
820
+ const rec = sessions.get(promptMatch[1]!);
821
+ if (!rec) {
822
+ res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
823
+ return;
824
+ }
825
+ let body = "";
826
+ req.on("data", (c) => (body += c));
827
+ req.on("end", async () => {
828
+ let text = "";
829
+ try {
830
+ text = String((JSON.parse(body || "{}") as { text?: string }).text ?? "");
831
+ } catch {
832
+ /* empty prompt */
833
+ }
834
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
835
+ rec.emit = (frame) => res.write(frame);
836
+ try {
837
+ await rec.agent.send(text, { onEvent: (e: AgentEvent) => res.write(sseFrame(e)) });
838
+ } catch (e) {
839
+ res.write(sseFrame({ type: "error", message: e instanceof Error ? e.message : String(e) }));
840
+ } finally {
841
+ rec.emit = null;
842
+ res.end();
843
+ }
844
+ });
845
+ return;
846
+ }
847
+ const approveMatch = req.method === "POST" && url.pathname.match(/^\/v1\/sessions\/([^/]+)\/approve$/);
848
+ if (approveMatch) {
849
+ const rec = sessions.get(approveMatch[1]!);
850
+ if (!rec) {
851
+ res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
852
+ return;
853
+ }
854
+ let body = "";
855
+ req.on("data", (c) => (body += c));
856
+ req.on("end", () => {
857
+ let ok = false;
858
+ try {
859
+ const { id, decision } = JSON.parse(body || "{}") as { id?: string; decision?: ApprovalDecision };
860
+ if (id && decision) ok = rec.registry.settle(id, decision);
861
+ } catch {
862
+ /* ok stays false */
863
+ }
864
+ res.writeHead(ok ? 200 : 404, { "content-type": "application/json" }).end(JSON.stringify({ ok }));
865
+ });
866
+ return;
867
+ }
868
+ const delMatch = req.method === "DELETE" && url.pathname.match(/^\/v1\/sessions\/([^/]+)$/);
869
+ if (delMatch) {
870
+ const existed = sessions.delete(delMatch[1]!);
871
+ res.writeHead(existed ? 200 : 404, { "content-type": "application/json" }).end(JSON.stringify({ ok: existed }));
872
+ return;
873
+ }
751
874
  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)"}`));
875
+ }).listen(port, () =>
876
+ console.log(
877
+ `ada HTTP API on http://localhost:${port} · model ${model || "(none — set one)"}\n` +
878
+ ` one-shot: POST /v1/prompt {"text":"…"}\n` +
879
+ ` interactive: POST /v1/sessions → {sessionId}\n` +
880
+ ` POST /v1/sessions/:id/prompt {"text":"…"} (SSE: text/tool_call/tool_result/approval_request/done)\n` +
881
+ ` POST /v1/sessions/:id/approve {"id":"…","decision":"yes"|"all"|"no"}`,
882
+ ),
883
+ );
753
884
  await new Promise(() => {}); // keep the process alive for the server
754
885
  return;
755
886
  }
package/src/sdk/index.ts CHANGED
@@ -1,21 +1,91 @@
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"; name: string; detail: string }
27
+ | { type: "tool_result"; 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
+ prompt(text: string, onEvent: (e: SessionEvent) => void): Promise<void>;
41
+ /** Answer a pending `approval_request` event by its id. */
42
+ approve(id: string, decision: "yes" | "all" | "no"): Promise<void>;
43
+ /** Free the session's resources server-side. (Does not delete the on-disk transcript.) */
44
+ close(): Promise<void>;
45
+ }
46
+
47
+ /** One on-disk session transcript, as returned by `listSessions()`. */
48
+ export interface SessionMeta {
49
+ file: string;
50
+ mtime: number;
51
+ title: string;
52
+ parent?: string;
53
+ }
54
+
12
55
  export interface AdaClient {
13
- /** Send a prompt; runs a fresh agent turn server-side and returns its final text. */
56
+ /** One-shot: runs a fresh agent turn server-side (no memory between calls) and returns its final text. */
14
57
  prompt(text: string, opts?: { model?: string }): Promise<PromptResult>;
58
+ /**
59
+ * Start a persistent, streaming session — the Cursor-style integration point for an IDE.
60
+ * Pass `resume: "latest"` or a `file` from `listSessions()` to reattach an existing conversation
61
+ * (e.g. after `ada serve` restarted and the old in-memory sessionId is gone).
62
+ */
63
+ session(opts?: { model?: string; resume?: string }): Promise<AdaSession>;
64
+ /** On-disk session transcripts, newest first — for building a "resume which conversation?" picker. */
65
+ listSessions(): Promise<SessionMeta[]>;
15
66
  /** Server health + the default model. */
16
67
  health(): Promise<{ ok: boolean; model?: string }>;
17
68
  }
18
69
 
70
+ async function streamSse(res: Response, onEvent: (e: SessionEvent) => void): Promise<void> {
71
+ if (!res.ok || !res.body) throw new Error(`ada ${res.status}: ${await res.text().catch(() => res.statusText)}`);
72
+ const reader = res.body.getReader();
73
+ const decoder = new TextDecoder();
74
+ let buf = "";
75
+ for (;;) {
76
+ const { done, value } = await reader.read();
77
+ if (done) break;
78
+ buf += decoder.decode(value, { stream: true });
79
+ let idx: number;
80
+ while ((idx = buf.indexOf("\n\n")) >= 0) {
81
+ const frame = buf.slice(0, idx);
82
+ buf = buf.slice(idx + 2);
83
+ const line = frame.split("\n").find((l) => l.startsWith("data: "));
84
+ if (line) onEvent(JSON.parse(line.slice(6)) as SessionEvent);
85
+ }
86
+ }
87
+ }
88
+
19
89
  export function createClient(baseUrl = "http://localhost:8788"): AdaClient {
20
90
  const url = baseUrl.replace(/\/+$/, "");
21
91
  return {
@@ -28,6 +98,44 @@ export function createClient(baseUrl = "http://localhost:8788"): AdaClient {
28
98
  if (!res.ok) throw new Error(`ada ${res.status}: ${await res.text().catch(() => res.statusText)}`);
29
99
  return (await res.json()) as PromptResult;
30
100
  },
101
+ async session(opts) {
102
+ const res = await fetch(`${url}/v1/sessions`, {
103
+ method: "POST",
104
+ headers: { "content-type": "application/json" },
105
+ body: JSON.stringify({ model: opts?.model, resume: opts?.resume }),
106
+ });
107
+ if (!res.ok) throw new Error(`ada ${res.status}: ${await res.text().catch(() => res.statusText)}`);
108
+ const { sessionId, file, resumed } = (await res.json()) as { sessionId: string; file: string; resumed: boolean };
109
+ return {
110
+ id: sessionId,
111
+ file,
112
+ resumed,
113
+ async prompt(text, onEvent) {
114
+ const r = await fetch(`${url}/v1/sessions/${sessionId}/prompt`, {
115
+ method: "POST",
116
+ headers: { "content-type": "application/json" },
117
+ body: JSON.stringify({ text }),
118
+ });
119
+ await streamSse(r, onEvent);
120
+ },
121
+ async approve(id, decision) {
122
+ const r = await fetch(`${url}/v1/sessions/${sessionId}/approve`, {
123
+ method: "POST",
124
+ headers: { "content-type": "application/json" },
125
+ body: JSON.stringify({ id, decision }),
126
+ });
127
+ if (!r.ok) throw new Error(`ada ${r.status}: could not settle approval ${id}`);
128
+ },
129
+ async close() {
130
+ await fetch(`${url}/v1/sessions/${sessionId}`, { method: "DELETE" });
131
+ },
132
+ };
133
+ },
134
+ async listSessions() {
135
+ const res = await fetch(`${url}/v1/sessions`);
136
+ if (!res.ok) throw new Error(`ada ${res.status}: ${await res.text().catch(() => res.statusText)}`);
137
+ return ((await res.json()) as { sessions: SessionMeta[] }).sessions;
138
+ },
31
139
  async health() {
32
140
  const res = await fetch(`${url}/health`);
33
141
  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");
@@ -299,6 +311,23 @@ async function main(): Promise<void> {
299
311
  const jid = startJob("selfcheck job", async () => "job-done-ok");
300
312
  await new Promise((r) => setTimeout(r, 30));
301
313
  assert.ok(renderJobs().includes(jid) && /job-done-ok/.test(renderJobs()), "background job runs and reports its result");
314
+
315
+ // --- agent-server helpers: SSE framing, id uniqueness, approval correlation (no live model needed) ---
316
+ {
317
+ const { sseFrame, newId, ApprovalRegistry } = await import("./client/agent-server.ts");
318
+ assert.equal(sseFrame({ type: "done", text: "hi" }), 'data: {"type":"done","text":"hi"}\n\n', "sseFrame formats one data: frame");
319
+ const a = newId("sess");
320
+ const b = newId("sess");
321
+ assert.ok(a.startsWith("sess_") && a !== b, "newId is prefixed and unique");
322
+
323
+ const registry = new ApprovalRegistry();
324
+ const { id, promise } = registry.wait();
325
+ assert.equal(registry.size, 1, "wait() tracks one pending approval");
326
+ assert.ok(registry.settle(id, "yes"), "settle() resolves a known pending approval");
327
+ assert.equal(await promise, "yes", "the waiting promise resolves with the decision");
328
+ assert.equal(registry.size, 0, "settle() clears the pending entry");
329
+ assert.equal(registry.settle("nope", "no"), false, "settle() on an unknown id returns false");
330
+ }
302
331
  assert.equal((await toolByName.get("web_fetch")!.run({ url: "http://127.0.0.1/x" })).isError, true, "web_fetch blocks loopback (SSRF guard)");
303
332
 
304
333
  // --- destructive classifier: real dangers flagged; everyday redirects are not (2>/dev/null bug) ---