@ronana/mcp 0.3.1 → 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 +15 -0
- package/dist/cli.js +29 -11
- package/dist/listen.d.ts +56 -0
- package/dist/listen.js +186 -0
- package/dist/oauth.d.ts +2 -0
- package/dist/oauth.js +35 -12
- package/dist/remote.js +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,6 +39,7 @@ ronana generate get <id> | wait <id> [--out dir]
|
|
|
39
39
|
ronana generate list
|
|
40
40
|
ronana upscale <id | file | url> --resolution 4k --out ./renders
|
|
41
41
|
ronana folder list | add <folder> <id…>
|
|
42
|
+
ronana listen [--with claude|codex|gemini|ollama|command] [--model m]
|
|
42
43
|
```
|
|
43
44
|
|
|
44
45
|
Every field from `model get` is a flag (`--aspect_ratio 16:9`, `--seed 7`).
|
|
@@ -53,6 +54,14 @@ the full list.
|
|
|
53
54
|
the same command run twice makes two generations. Pass `--idempotency-key` to
|
|
54
55
|
make a retry bill once.
|
|
55
56
|
|
|
57
|
+
`listen` lets the Ronana studio send work to an AI on your computer. While it
|
|
58
|
+
runs, the studio's assistant and agent list it under their model menu, and
|
|
59
|
+
Enhance, assistant messages and agent turns go to it instead of Ronana's own
|
|
60
|
+
models, so they cost no credits. Renders still do, and agent cards wait for
|
|
61
|
+
your Confirm. `--with` picks the AI (Claude Code by default); `--with command
|
|
62
|
+
--command "…"` runs any program that reads the prompt on stdin and prints its
|
|
63
|
+
answer.
|
|
64
|
+
|
|
56
65
|
## Use Ronana from an AI app
|
|
57
66
|
|
|
58
67
|
Most AI apps connect to Ronana's remote MCP server directly. Add this URL as a
|
|
@@ -112,6 +121,7 @@ library.
|
|
|
112
121
|
| --- | --- |
|
|
113
122
|
| `list_models` | List the available models. |
|
|
114
123
|
| `get_model` | Get one model's contract: its input schema. |
|
|
124
|
+
| `get_prompt_guide` | Get the guide for writing a prompt for one model. |
|
|
115
125
|
| `estimate_cost` | Estimate the credit cost of a generation. Takes the same `model` and `input` as `generate`. |
|
|
116
126
|
| `generate` | Start an image or video generation. |
|
|
117
127
|
| `get_generation` | Get one generation and its download link. Pass `wait_seconds` (up to 45) to wait for the result in one call. |
|
|
@@ -124,6 +134,11 @@ library.
|
|
|
124
134
|
| `list_folders` | List the folders in the library. |
|
|
125
135
|
| `add_to_folder` | File library items into a folder. |
|
|
126
136
|
| `get_account` | Get the plan and credit balance. |
|
|
137
|
+
| `wait_for_requests` | Wait for Enhance, assistant and agent work from the studio. While an app calls it, the studio lists it as listening. |
|
|
138
|
+
| `get_request` | Read one request the studio sent, by its id. |
|
|
139
|
+
| `request_progress` | Tell the studio you are still working on a request you took, so a slow answer is not treated as abandoned. |
|
|
140
|
+
| `agent_tool` | Look up models, prompt guides and prices for an agent request. |
|
|
141
|
+
| `reply` | Answer a request: a prompt for the composer, a message, or cards for the board. |
|
|
127
142
|
|
|
128
143
|
A generate call takes a `model` and an `input`, exactly as `get_model` shows it
|
|
129
144
|
in `input_schema`. A media field takes a public https URL or a Ronana id
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// ronana generate list [--limit n]
|
|
15
15
|
// ronana upscale <id | file | https-url> [--resolution r] [--model m] [--wait] [--out dir]
|
|
16
16
|
// ronana folder list | add <folder> <id…>
|
|
17
|
+
// ronana listen [--with claude|codex|gemini|ollama|command] [--model m] [--command "…"]
|
|
17
18
|
import { randomUUID } from "node:crypto";
|
|
18
19
|
import { createWriteStream, existsSync, statSync } from "node:fs";
|
|
19
20
|
import { mkdir } from "node:fs/promises";
|
|
@@ -22,6 +23,7 @@ import { Readable } from "node:stream";
|
|
|
22
23
|
import { pipeline } from "node:stream/promises";
|
|
23
24
|
import { login, logout, readCredentials } from "./oauth.js";
|
|
24
25
|
import { callTool, remoteClient, ToolError, uploadFile } from "./remote.js";
|
|
26
|
+
import { listen, runnerFor } from "./listen.js";
|
|
25
27
|
import { buildInput, flag, has, isRemoteValue, parseArgs, replaceLocalMedia, typeOf } from "./input.js";
|
|
26
28
|
const HELP = `Usage: ronana <command>
|
|
27
29
|
|
|
@@ -40,6 +42,9 @@ const HELP = `Usage: ronana <command>
|
|
|
40
42
|
upscale <id|file|url> Upscale a video (costs credits; --resolution, --model)
|
|
41
43
|
folder list Your library folders
|
|
42
44
|
folder add <folder> <id…> File items into a folder (made if missing)
|
|
45
|
+
listen Let the studio send Enhance and agent work to an AI
|
|
46
|
+
on this computer (--with claude|codex|gemini|ollama|command,
|
|
47
|
+
--model m, --command "…", --app "name"). No credits.
|
|
43
48
|
|
|
44
49
|
Generate flags:
|
|
45
50
|
--<field> <value> Any field from \`model get\`. Repeat for lists.
|
|
@@ -173,6 +178,14 @@ async function withRemote(fn) {
|
|
|
173
178
|
await remote.close().catch(() => null);
|
|
174
179
|
}
|
|
175
180
|
}
|
|
181
|
+
/** A queued job: print its id, or with --wait / --out follow it to the end. */
|
|
182
|
+
async function queuedThenFinish(remote, queued, args) {
|
|
183
|
+
if (!has(args, "wait") && !has(args, "out")) {
|
|
184
|
+
return emit(queued, () => { print(queued.id); note(`Check it with \`ronana generate wait ${queued.id}\`.`); });
|
|
185
|
+
}
|
|
186
|
+
note(`Queued ${queued.id}.`);
|
|
187
|
+
await finish(await waitFor(remote, queued.id, args), args);
|
|
188
|
+
}
|
|
176
189
|
async function generateCommand(sub, args) {
|
|
177
190
|
const [target] = args.positional;
|
|
178
191
|
if (sub === "list") {
|
|
@@ -208,11 +221,7 @@ async function generateCommand(sub, args) {
|
|
|
208
221
|
command run twice in ten minutes would return the first generation. */
|
|
209
222
|
const idempotency_key = flag(args, "idempotency_key") ?? randomUUID();
|
|
210
223
|
const queued = await callTool(remote, "generate", { model: contract.id, input, idempotency_key });
|
|
211
|
-
|
|
212
|
-
return emit(queued, () => { print(queued.id); note(`Check it with \`ronana generate wait ${queued.id}\`.`); });
|
|
213
|
-
}
|
|
214
|
-
note(`Queued ${queued.id}.`);
|
|
215
|
-
await finish(await waitFor(remote, queued.id, args), args);
|
|
224
|
+
await queuedThenFinish(remote, queued, args);
|
|
216
225
|
});
|
|
217
226
|
}
|
|
218
227
|
async function modelCommand(sub, args) {
|
|
@@ -263,11 +272,7 @@ async function upscaleCommand(args) {
|
|
|
263
272
|
...(flag(args, "resolution") ? { resolution: flag(args, "resolution") } : {}),
|
|
264
273
|
idempotency_key: flag(args, "idempotency_key") ?? randomUUID(),
|
|
265
274
|
});
|
|
266
|
-
|
|
267
|
-
return emit(queued, () => { print(queued.id); note(`Check it with \`ronana generate wait ${queued.id}\`.`); });
|
|
268
|
-
}
|
|
269
|
-
note(`Queued ${queued.id}.`);
|
|
270
|
-
await finish(await waitFor(remote, queued.id, args), args);
|
|
275
|
+
await queuedThenFinish(remote, queued, args);
|
|
271
276
|
});
|
|
272
277
|
}
|
|
273
278
|
async function folderCommand(sub, args) {
|
|
@@ -317,7 +322,12 @@ async function main() {
|
|
|
317
322
|
}
|
|
318
323
|
if (action === "status") {
|
|
319
324
|
const credentials = readCredentials();
|
|
320
|
-
|
|
325
|
+
if (!credentials)
|
|
326
|
+
return emit({ signed_in: false, base_url: null }, () => print("Not signed in."));
|
|
327
|
+
// Ask the server: a sign-in stored here may have been disconnected on
|
|
328
|
+
// the website or have expired, and only the server knows.
|
|
329
|
+
const problem = await withRemote((r) => callTool(r, "get_account")).then(() => null, (error) => error instanceof ToolError ? null : error instanceof Error ? error.message : String(error));
|
|
330
|
+
return emit({ signed_in: problem === null, base_url: credentials.baseUrl, ...(problem ? { error: problem } : {}) }, () => print(problem ?? `Signed in to ${credentials.baseUrl}.`));
|
|
321
331
|
}
|
|
322
332
|
throw new Error("Use auth login | logout | status.");
|
|
323
333
|
}
|
|
@@ -342,6 +352,14 @@ async function main() {
|
|
|
342
352
|
case "upscale": return upscaleCommand(args);
|
|
343
353
|
case "folder":
|
|
344
354
|
case "folders": return folderCommand(command === "folders" ? "list" : sub, args);
|
|
355
|
+
case "listen": {
|
|
356
|
+
const runner = runnerFor(flag(args, "with") ?? "claude", { model: flag(args, "model"), command: flag(args, "command"), app: flag(args, "app") });
|
|
357
|
+
const remote = await remoteClient();
|
|
358
|
+
const stop = () => { void remote.close().catch(() => null).finally(() => process.exit(0)); };
|
|
359
|
+
process.once("SIGINT", stop);
|
|
360
|
+
process.once("SIGTERM", stop);
|
|
361
|
+
return listen(remote, runner, (line) => process.stderr.write(`${line}\n`));
|
|
362
|
+
}
|
|
345
363
|
default: throw new Error(`Unknown command "${command}". Run \`ronana --help\`.`);
|
|
346
364
|
}
|
|
347
365
|
}
|
package/dist/listen.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type remoteClient } from "./remote.js";
|
|
2
|
+
type Remote = Awaited<ReturnType<typeof remoteClient>>;
|
|
3
|
+
export interface Runner {
|
|
4
|
+
app: string;
|
|
5
|
+
command: string;
|
|
6
|
+
args: string[];
|
|
7
|
+
shell: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** The AIs `--with` knows. Each reads the prompt on stdin and prints its answer. */
|
|
10
|
+
export declare function runnerFor(name: string, options: {
|
|
11
|
+
model?: string;
|
|
12
|
+
command?: string;
|
|
13
|
+
app?: string;
|
|
14
|
+
}): Runner;
|
|
15
|
+
/** Run the AI once. Resolves with what it printed. */
|
|
16
|
+
export declare function runOnce(runner: Runner, prompt: string, timeoutMs: number): Promise<string>;
|
|
17
|
+
interface EnhanceRequest {
|
|
18
|
+
id: string;
|
|
19
|
+
kind: "enhance";
|
|
20
|
+
model: {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
};
|
|
24
|
+
draft: string;
|
|
25
|
+
brief?: string;
|
|
26
|
+
guide: string;
|
|
27
|
+
max_chars: number;
|
|
28
|
+
}
|
|
29
|
+
interface AgentRequest {
|
|
30
|
+
id: string;
|
|
31
|
+
kind: "agent";
|
|
32
|
+
messages: {
|
|
33
|
+
role: string;
|
|
34
|
+
content: string;
|
|
35
|
+
}[];
|
|
36
|
+
references: {
|
|
37
|
+
target: unknown;
|
|
38
|
+
kind: string;
|
|
39
|
+
label: string;
|
|
40
|
+
duration_s?: number;
|
|
41
|
+
}[];
|
|
42
|
+
context?: string;
|
|
43
|
+
approval_mode: string;
|
|
44
|
+
max_cards: number;
|
|
45
|
+
card_schema: unknown;
|
|
46
|
+
}
|
|
47
|
+
export type StudioRequest = EnhanceRequest | AgentRequest;
|
|
48
|
+
/** The prompt between <prompt> tags, or the whole answer when it has none. */
|
|
49
|
+
export declare function extractPrompt(text: string): string;
|
|
50
|
+
/** The first JSON object in the answer, fenced or bare. */
|
|
51
|
+
export declare function extractJson(text: string): Record<string, unknown> | null;
|
|
52
|
+
export declare function enhancePrompt(request: EnhanceRequest, refusal?: string): string;
|
|
53
|
+
export declare function agentPrompt(request: AgentRequest, catalog: string, refusal?: string): string;
|
|
54
|
+
/** Listen until Ctrl+C. */
|
|
55
|
+
export declare function listen(remote: Remote, runner: Runner, log: (line: string) => void): Promise<never>;
|
|
56
|
+
export {};
|
package/dist/listen.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// `ronana listen`: keep an AI on this computer listening for work from the
|
|
2
|
+
// Ronana studio. While it runs, the studio lists it under its model menus and
|
|
3
|
+
// sends it Enhance and agent requests instead of using Ronana's own models, so
|
|
4
|
+
// the thinking runs on the user's own AI and costs no Ronana credits.
|
|
5
|
+
//
|
|
6
|
+
// The loop holds `wait_for_requests` open, runs the chosen AI once per request
|
|
7
|
+
// with the prompt on stdin, and sends its answer with `reply`. The server
|
|
8
|
+
// checks every reply; a refused one is handed back to the AI once to fix.
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { callTool, ToolError } from "./remote.js";
|
|
11
|
+
/** The AIs `--with` knows. Each reads the prompt on stdin and prints its answer. */
|
|
12
|
+
export function runnerFor(name, options) {
|
|
13
|
+
const shell = process.platform === "win32";
|
|
14
|
+
const app = options.app?.trim();
|
|
15
|
+
switch (name) {
|
|
16
|
+
case "claude": return { app: app || "Claude Code", command: "claude", args: ["-p", "--output-format", "text", ...(options.model ? ["--model", options.model] : [])], shell };
|
|
17
|
+
case "codex": return { app: app || "Codex", command: "codex", args: ["exec", ...(options.model ? ["-m", options.model] : []), "-"], shell };
|
|
18
|
+
case "gemini": return { app: app || "Gemini CLI", command: "gemini", args: options.model ? ["-m", options.model] : [], shell };
|
|
19
|
+
case "ollama":
|
|
20
|
+
if (!options.model)
|
|
21
|
+
throw new Error("--with ollama needs --model <name>, e.g. --model llama3.1.");
|
|
22
|
+
return { app: app || `Ollama ${options.model}`, command: "ollama", args: ["run", options.model], shell };
|
|
23
|
+
case "command":
|
|
24
|
+
if (!options.command?.trim())
|
|
25
|
+
throw new Error("--with command needs --command \"<your command>\"; it gets the prompt on stdin.");
|
|
26
|
+
return { app: app || "Desktop AI", command: options.command, args: [], shell: true };
|
|
27
|
+
default: throw new Error(`Unknown --with "${name}". Use claude, codex, gemini, ollama or command.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Run the AI once. Resolves with what it printed. */
|
|
31
|
+
export function runOnce(runner, prompt, timeoutMs) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const child = spawn(runner.command, runner.args, { shell: runner.shell, stdio: ["pipe", "pipe", "pipe"] });
|
|
34
|
+
let out = "";
|
|
35
|
+
let err = "";
|
|
36
|
+
const timer = setTimeout(() => { child.kill(); reject(new Error(`${runner.app} took longer than ${Math.round(timeoutMs / 1000)}s.`)); }, timeoutMs);
|
|
37
|
+
child.stdout.on("data", (chunk) => { out += chunk.toString("utf8"); });
|
|
38
|
+
child.stderr.on("data", (chunk) => { err += chunk.toString("utf8"); });
|
|
39
|
+
child.on("error", (error) => { clearTimeout(timer); reject(new Error(`Could not start ${runner.command}: ${error.message}`)); });
|
|
40
|
+
child.on("close", (code) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
if (code === 0)
|
|
43
|
+
resolve(out);
|
|
44
|
+
else
|
|
45
|
+
reject(new Error(`${runner.app} exited with code ${code}${err.trim() ? `: ${err.trim().split("\n").slice(-3).join(" ")}` : ""}`));
|
|
46
|
+
});
|
|
47
|
+
child.stdin.end(prompt);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** The prompt between <prompt> tags, or the whole answer when it has none. */
|
|
51
|
+
export function extractPrompt(text) {
|
|
52
|
+
const tagged = /<prompt>([\s\S]*?)(?:<\/prompt>|$)/i.exec(text);
|
|
53
|
+
return (tagged ? tagged[1] : text).trim();
|
|
54
|
+
}
|
|
55
|
+
/** The first JSON object in the answer, fenced or bare. */
|
|
56
|
+
export function extractJson(text) {
|
|
57
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
|
|
58
|
+
const candidates = [fenced?.[1], text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1)];
|
|
59
|
+
for (const raw of candidates) {
|
|
60
|
+
if (!raw?.trim())
|
|
61
|
+
continue;
|
|
62
|
+
try {
|
|
63
|
+
const value = JSON.parse(raw);
|
|
64
|
+
if (value && typeof value === "object" && !Array.isArray(value))
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
catch { /* try the next shape */ }
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
export function enhancePrompt(request, refusal) {
|
|
72
|
+
return [
|
|
73
|
+
`Rewrite this draft into the best prompt for ${request.model.name}, following the guide.`,
|
|
74
|
+
"Keep every reference token exactly as the draft writes it (@image1, @video1, @audio1).",
|
|
75
|
+
`Answer with the final prompt only, between <prompt> and </prompt>, at most ${request.max_chars} characters.`,
|
|
76
|
+
"", "GUIDE:", request.guide,
|
|
77
|
+
...(request.brief ? ["", "BRIEF:", request.brief] : []),
|
|
78
|
+
"", "DRAFT:", request.draft,
|
|
79
|
+
...(refusal ? ["", `Your last answer was refused: ${refusal}`, "Fix that and answer again."] : []),
|
|
80
|
+
].join("\n");
|
|
81
|
+
}
|
|
82
|
+
export function agentPrompt(request, catalog, refusal) {
|
|
83
|
+
const transcript = request.messages.map((m) => `${m.role === "user" ? "USER" : "AGENT"}: ${m.content}`).join("\n\n");
|
|
84
|
+
return [
|
|
85
|
+
"You are the agent on the user's Ronana board. Answer their latest message.",
|
|
86
|
+
`You may propose up to ${request.max_cards} generation cards; each waits for the user's Confirm and spends nothing until then.`,
|
|
87
|
+
"Answer with ONE JSON object and nothing else: {\"message\": \"what the user reads\", \"cards\": [ ... ]}.",
|
|
88
|
+
"Each card follows CARD SCHEMA; modelId must come from MODELS.",
|
|
89
|
+
"", "MODELS:", catalog,
|
|
90
|
+
"", "CARD SCHEMA:", JSON.stringify(request.card_schema),
|
|
91
|
+
...(request.references.length ? ["", "ATTACHED:", JSON.stringify(request.references)] : []),
|
|
92
|
+
...(request.context ? ["", "CONTEXT:", request.context] : []),
|
|
93
|
+
"", "CONVERSATION:", transcript,
|
|
94
|
+
...(refusal ? ["", `Your last answer was refused: ${refusal}`, "Fix that and answer again."] : []),
|
|
95
|
+
].join("\n");
|
|
96
|
+
}
|
|
97
|
+
const ENHANCE_TIMEOUT_MS = 120_000;
|
|
98
|
+
/** The least time between two empty waits. */
|
|
99
|
+
const IDLE_FLOOR_MS = 5_000;
|
|
100
|
+
// Inside the board's own wait for an answer.
|
|
101
|
+
const AGENT_TIMEOUT_MS = 150_000;
|
|
102
|
+
/** "Still working" every 15s while the AI writes. The studio treats a taken
|
|
103
|
+
* request that goes quiet for 45s as abandoned, so this is what lets a slow
|
|
104
|
+
* answer take its time. Best effort: a missed beat is not an error. */
|
|
105
|
+
const PROGRESS_EVERY_MS = 15_000;
|
|
106
|
+
async function answer(remote, runner, request, log) {
|
|
107
|
+
const beat = setInterval(() => { void callTool(remote, "request_progress", { id: request.id }).catch(() => { }); }, PROGRESS_EVERY_MS);
|
|
108
|
+
try {
|
|
109
|
+
await answerOnce(remote, runner, request, log);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
clearInterval(beat);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function answerOnce(remote, runner, request, log) {
|
|
116
|
+
let catalog = "";
|
|
117
|
+
if (request.kind === "agent") {
|
|
118
|
+
catalog = JSON.stringify(await callTool(remote, "agent_tool", { id: request.id, name: "list_models", input: {} }));
|
|
119
|
+
}
|
|
120
|
+
let refusal;
|
|
121
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
122
|
+
const prompt = request.kind === "enhance" ? enhancePrompt(request, refusal) : agentPrompt(request, catalog, refusal);
|
|
123
|
+
const output = await runOnce(runner, prompt, request.kind === "enhance" ? ENHANCE_TIMEOUT_MS : AGENT_TIMEOUT_MS);
|
|
124
|
+
let args;
|
|
125
|
+
if (request.kind === "enhance") {
|
|
126
|
+
args = { id: request.id, prompt: extractPrompt(output) };
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
const json = extractJson(output);
|
|
130
|
+
if (!json) {
|
|
131
|
+
refusal = "it was not one JSON object.";
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
args = { id: request.id, message: typeof json.message === "string" ? json.message : "", cards: Array.isArray(json.cards) ? json.cards : [] };
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await callTool(remote, "reply", args);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (!(error instanceof ToolError) || error.error.code !== "INVALID_REPLY")
|
|
142
|
+
throw error;
|
|
143
|
+
refusal = error.message;
|
|
144
|
+
log(` refused (${refusal}); asking ${runner.app} to fix it`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
throw new Error(`${runner.app} could not produce an answer the studio accepts${refusal ? `: ${refusal}` : "."}`);
|
|
148
|
+
}
|
|
149
|
+
/** Listen until Ctrl+C. */
|
|
150
|
+
export async function listen(remote, runner, log) {
|
|
151
|
+
log(`Listening as ${runner.app}. Ronana's studio now sends Enhance and agent requests here (Ctrl+C to stop).`);
|
|
152
|
+
let backoff = 0;
|
|
153
|
+
for (;;) {
|
|
154
|
+
try {
|
|
155
|
+
const asked = Date.now();
|
|
156
|
+
const { request } = await callTool(remote, "wait_for_requests", { app: runner.app });
|
|
157
|
+
backoff = 0;
|
|
158
|
+
if (!request) {
|
|
159
|
+
// The server answers at once (not after holding) when its budget of
|
|
160
|
+
// held calls is spent: pause instead of asking again in a tight loop.
|
|
161
|
+
if (Date.now() - asked < IDLE_FLOOR_MS)
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, IDLE_FLOOR_MS));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const started = Date.now();
|
|
166
|
+
log(`● ${request.kind} ${request.id}${request.kind === "enhance" ? ` · ${request.model.name}` : ""}`);
|
|
167
|
+
try {
|
|
168
|
+
await answer(remote, runner, request, log);
|
|
169
|
+
log(`✓ sent · ${((Date.now() - started) / 1000).toFixed(1)}s`);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
log(`✗ ${error instanceof Error ? error.message : String(error)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
// A refused sign-in or a missing plan will not fix itself: stop.
|
|
177
|
+
if (error instanceof ToolError && (error.error.code === "SCOPE_REQUIRED" || error.error.code === "UNAUTHORIZED"))
|
|
178
|
+
throw error;
|
|
179
|
+
if (error instanceof Error && /sign.?in|401|pricing/i.test(error.message))
|
|
180
|
+
throw error;
|
|
181
|
+
backoff = Math.min(30_000, backoff ? backoff * 2 : 2_000);
|
|
182
|
+
log(`Connection problem (${error instanceof Error ? error.message : String(error)}). Retrying in ${backoff / 1000}s.`);
|
|
183
|
+
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
package/dist/oauth.d.ts
CHANGED
|
@@ -8,4 +8,6 @@ export interface StoredCredentials {
|
|
|
8
8
|
export declare function readCredentials(): StoredCredentials | null;
|
|
9
9
|
export declare function logout(): Promise<void>;
|
|
10
10
|
export declare function login(baseUrl?: string, openInBrowser?: boolean): Promise<void>;
|
|
11
|
+
/** What a user reads when the stored sign-in no longer works on the server. */
|
|
12
|
+
export declare const SIGN_IN_ENDED = "Your Ronana sign-in has ended (disconnected or expired). Run `ronana auth login` again.";
|
|
11
13
|
export declare function accessToken(): Promise<string>;
|
package/dist/oauth.js
CHANGED
|
@@ -84,24 +84,37 @@ export async function login(baseUrl = process.env.RONANA_BASE_URL ?? "https://ro
|
|
|
84
84
|
let fail;
|
|
85
85
|
const code = new Promise((ok, no) => { settle = ok; fail = no; });
|
|
86
86
|
const timer = setTimeout(() => fail(new Error("Sign-in timed out. Run `ronana auth login` again.")), LOGIN_TIMEOUT_MS);
|
|
87
|
-
|
|
87
|
+
/* The browser tab waits on this response until the code has been traded
|
|
88
|
+
for tokens, so it only says "connected" once that is true. */
|
|
89
|
+
let held = null;
|
|
90
|
+
let answered = false;
|
|
91
|
+
const finish = (text, status = 200) => {
|
|
92
|
+
if (!held)
|
|
93
|
+
return;
|
|
94
|
+
held.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" }).end(text);
|
|
95
|
+
held = null;
|
|
96
|
+
};
|
|
97
|
+
const close = () => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
finish("Sign-in did not finish. See your terminal for why.", 500);
|
|
100
|
+
server.close();
|
|
101
|
+
};
|
|
88
102
|
const server = createServer((req, res) => {
|
|
89
103
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
90
104
|
// Anything but our own callback (a favicon, a prefetch, a stale tab from an
|
|
91
|
-
// earlier sign-in) is ignored, so it cannot end the sign-in
|
|
92
|
-
if (url.pathname !== "/callback" || url.searchParams.get("state") !== state) {
|
|
105
|
+
// earlier sign-in, a repeat) is ignored, so it cannot end the sign-in.
|
|
106
|
+
if (answered || url.pathname !== "/callback" || url.searchParams.get("state") !== state) {
|
|
93
107
|
res.writeHead(404).end();
|
|
94
108
|
return;
|
|
95
109
|
}
|
|
110
|
+
answered = true;
|
|
96
111
|
if (url.searchParams.get("error")) {
|
|
97
|
-
res.writeHead(400).end("Authorization was not completed.");
|
|
112
|
+
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }).end("Authorization was not completed.");
|
|
98
113
|
fail(new Error(url.searchParams.get("error_description") ?? url.searchParams.get("error")));
|
|
114
|
+
return;
|
|
99
115
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
settle(url.searchParams.get("code") ?? "");
|
|
103
|
-
}
|
|
104
|
-
close();
|
|
116
|
+
held = res;
|
|
117
|
+
settle(url.searchParams.get("code") ?? "");
|
|
105
118
|
});
|
|
106
119
|
server.once("error", (error) => { clearTimeout(timer); reject(error); });
|
|
107
120
|
server.listen(0, "127.0.0.1", () => {
|
|
@@ -110,7 +123,7 @@ export async function login(baseUrl = process.env.RONANA_BASE_URL ?? "https://ro
|
|
|
110
123
|
close();
|
|
111
124
|
return reject(new Error("Loopback callback failed."));
|
|
112
125
|
}
|
|
113
|
-
resolve({ redirectUri: `http://127.0.0.1:${address.port}/callback`, code, close });
|
|
126
|
+
resolve({ redirectUri: `http://127.0.0.1:${address.port}/callback`, code, finish, close });
|
|
114
127
|
});
|
|
115
128
|
});
|
|
116
129
|
// Every exit below (refused registration, timeout, a refused token) frees the
|
|
@@ -170,7 +183,8 @@ ${authorize}
|
|
|
170
183
|
refreshToken: tokens.refresh_token,
|
|
171
184
|
expiresAt: Date.now() + Number(tokens.expires_in ?? 900) * 1000,
|
|
172
185
|
});
|
|
173
|
-
|
|
186
|
+
callback.finish("Ronana is connected. You can close this window.");
|
|
187
|
+
if (previous)
|
|
174
188
|
await revoke(previous);
|
|
175
189
|
}
|
|
176
190
|
finally {
|
|
@@ -214,6 +228,8 @@ async function withRefreshLock(fn) {
|
|
|
214
228
|
catch { /* Gone already. */ }
|
|
215
229
|
}
|
|
216
230
|
}
|
|
231
|
+
/** What a user reads when the stored sign-in no longer works on the server. */
|
|
232
|
+
export const SIGN_IN_ENDED = "Your Ronana sign-in has ended (disconnected or expired). Run `ronana auth login` again.";
|
|
217
233
|
export async function accessToken() {
|
|
218
234
|
const stored = readCredentials();
|
|
219
235
|
if (!stored)
|
|
@@ -237,8 +253,15 @@ export async function accessToken() {
|
|
|
237
253
|
// server's own words ("session not found") would read as something else.
|
|
238
254
|
if (error instanceof TypeError)
|
|
239
255
|
throw error;
|
|
240
|
-
|
|
256
|
+
// A login in another process replaces the stored sign-in and revokes the
|
|
257
|
+
// one we were refreshing: carry on with the new one instead.
|
|
258
|
+
const replaced = readCredentials();
|
|
259
|
+
if (replaced && replaced.clientId !== current.clientId && fresh(replaced))
|
|
260
|
+
return { replaced: replaced.accessToken };
|
|
261
|
+
throw new Error(SIGN_IN_ENDED);
|
|
241
262
|
});
|
|
263
|
+
if (typeof tokens.replaced === "string")
|
|
264
|
+
return tokens.replaced;
|
|
242
265
|
if (typeof tokens.access_token !== "string")
|
|
243
266
|
throw new Error("OAuth refresh failed.");
|
|
244
267
|
const next = {
|
package/dist/remote.js
CHANGED
|
@@ -6,7 +6,7 @@ import { readFile } from "node:fs/promises";
|
|
|
6
6
|
import { basename, extname } from "node:path";
|
|
7
7
|
import { Readable } from "node:stream";
|
|
8
8
|
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
9
|
-
import { accessToken, readCredentials } from "./oauth.js";
|
|
9
|
+
import { accessToken, readCredentials, SIGN_IN_ENDED } from "./oauth.js";
|
|
10
10
|
export async function remoteClient() {
|
|
11
11
|
const credentials = readCredentials();
|
|
12
12
|
if (!credentials)
|
|
@@ -20,6 +20,10 @@ export async function remoteClient() {
|
|
|
20
20
|
await client.connect(transport);
|
|
21
21
|
}
|
|
22
22
|
catch (error) {
|
|
23
|
+
// A 401 on a token that was still in date: the server answers that for a
|
|
24
|
+
// connection disconnected on the website.
|
|
25
|
+
if (error instanceof Error && /\b401\b|unauthori[sz]ed|revoked/i.test(error.message))
|
|
26
|
+
throw new Error(SIGN_IN_ENDED);
|
|
23
27
|
// A 404 means either an account outside the MCP rollout or a server with no
|
|
24
28
|
// MCP at all (a wrong --base-url). Only a server that advertises the MCP
|
|
25
29
|
// resource can be refusing the account.
|