@nylorun/runtime 0.2.1-beta → 0.3.0-beta

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0-beta
4
+
5
+ ### Minor Changes
6
+
7
+ - 9c350be: Provide `nylorun dev` with optional Studio and browser opening, automatic development loopback CORS, and inferred Hono mount paths. Move local model selection to `.env/model.json` with legacy fallback and migration. Generate starters without copied launcher scripts, a top-level config directory, or a separate TypeScript build config. Release preparation must update the creator's Runtime compatibility pin together with these changes.
8
+
3
9
  ## 0.2.1-beta
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -8,7 +8,7 @@ import { Runtime, serveAgents } from "@nylorun/runtime";
8
8
  const runtime = new Runtime();
9
9
  app.route(
10
10
  "/agents",
11
- serveAgents({ agents, runtime, basePath: "/agents" })
11
+ serveAgents({ agents, runtime })
12
12
  );
13
13
  ```
14
14
 
@@ -16,22 +16,31 @@ app.route(
16
16
 
17
17
  The application owns Hono composition, authentication, CORS, logging, process lifecycle, and deployment. Runtime owns agent sessions, durability, media, and AG-UI/session protocol routes. Graceful shutdown is optional: if the application installs signal handlers and wants to drain live sessions, flush pending journal writes, and run optional agent cleanup, it should await `runtime.close()`. An application that does not install handlers exits normally on its host's shutdown policy; `runtime.close()` does not run on crash, OOM, or SIGKILL.
18
18
 
19
- Runtime publishes root-relative discovery and endpoint URLs. `basePath` must match the Hono mount path so advertised manifests and AG-UI endpoints resolve. Agent-scoped routes are `/:id/...` inside the router, so mounting at `/agents` with `basePath: "/agents"` yields `/agents/:id/...`. Pass the same prefix for other mounts:
19
+ Runtime publishes root-relative discovery and endpoint URLs. It infers the Hono
20
+ mount from each request URL (so a separate consumer `hono` install still works).
21
+ Mount at `/agents` or `/api/agents` without repeating that path in `serveAgents`.
22
+ Pass explicit `basePath` when a reverse proxy rewrites the public prefix.
20
23
 
21
- ```ts
22
- app.route(
23
- "/api/agents",
24
- serveAgents({ agents, runtime, basePath: "/api/agents" })
25
- );
26
- ```
24
+ `nylorun dev` enables local Studio connections automatically by setting `NYLORUN_DEV=1` for its child application. This allows HTTP/HTTPS browser origins on `localhost`, `127.0.0.1`, or `[::1]`, including Studio's fallback ports. Ordinary production startup does not enable this policy; the application owns production CORS and authorization.
27
25
 
28
26
  `getActor(context)` supplies an optional actor id and session context for newly created sessions. `getRequestMetadata(context)` supplies JSON-safe metadata for inbound messages. Application middleware remains responsible for authorizing every agent route.
29
27
 
30
28
  ## Commands
31
29
 
32
30
  - `nylorun configure`
31
+ - `nylorun dev [--no-studio] [--no-open]`
33
32
  - `nylorun studio --agent-url http://localhost:3000/agents [--port 4161] [--no-open]`
34
33
 
35
34
  Studio attaches to an application you run. Use your own TypeScript/build tooling and a Node adapter such as `@hono/node-server` when applicable. `projectAsset("agents/skills/catalog")` resolves bundled application assets from source or a compiled `dist/` deployment.
36
35
 
37
- Provider credentials are stored in `.env/auth.json`, and selection in `config/model.json`. `nylorun configure` can run before an agent graph is importable.
36
+ Provider credentials are stored in `.env/auth.json`, and selection in `.env/model.json`. `nylorun configure` can run before an agent graph is importable.
37
+
38
+ `nylorun dev` runs project-local `tsx watch src/index.ts`, waits for `/agents/v1/agents`, then starts project-local Studio. `PORT` defaults to 3000. `--no-studio` runs just the application; `--no-open` keeps the browser closed. Both flags can be combined. Ctrl-C stops both processes.
39
+
40
+ ### Upgrading an existing starter
41
+
42
+ After upgrading Runtime to a release containing `nylorun dev`, change the development script to `"dev": "nylorun dev"` and remove `dev:app` and `scripts/dev.mjs`. Remove the duplicated `basePath` option for normal Hono mounts.
43
+
44
+ Run `npm run configure` to move model selection to `.env/model.json`. Runtime reads the legacy `config/model.json` only when the new file is absent. Successful configuration removes the legacy file and its directory only if empty; credentials remain in `.env/auth.json`. Do not set `NYLORUN_DEV` in production.
45
+
46
+ The starter now uses one `tsconfig.json` with `rootDir: "."` and `outDir: "dist"`. Remove `noEmit` from that file and use `tsc --noEmit` for checks. Change the build's compiler invocation to `tsc -p tsconfig.json` before deleting `tsconfig.build.json`. Keep your existing asset-copy step. Projects whose checks include tests may retain separate build configuration.
package/dist/cli.js CHANGED
@@ -4,8 +4,10 @@ import { loadEnvFile } from "node:process";
4
4
  import { join } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { createRequire } from "node:module";
7
- import { ConfigurationCancelled, configureProvider } from "./model/configure.js";
8
- const usage = `nylorun <configure|studio>
7
+ import { ConfigurationCancelled, configureProvider, } from "./model/configure.js";
8
+ import { develop } from "./dev.js";
9
+ const usage = `nylorun <configure|dev|studio>
10
+ dev [--no-studio] [--no-open]
9
11
  configure
10
12
  studio --agent-url <http(s)-url> [--port <n>] [--no-open]`;
11
13
  async function startStudio(agentServerUrl, open, port) {
@@ -17,7 +19,11 @@ async function startStudio(agentServerUrl, open, port) {
17
19
  throw new Error("Install @nylorun/studio to use the Studio dashboard.");
18
20
  }
19
21
  const studio = await import(pathToFileURL(entry).href);
20
- return studio.startStudio({ agentServerUrl, open, ...(port === undefined ? {} : { port }) });
22
+ return studio.startStudio({
23
+ agentServerUrl,
24
+ open,
25
+ ...(port === undefined ? {} : { port }),
26
+ });
21
27
  }
22
28
  function parsePort(value) {
23
29
  if (value === undefined)
@@ -31,6 +37,10 @@ async function main() {
31
37
  const [command, ...args] = process.argv.slice(2);
32
38
  if (!command || command === "--help" || command === "-h")
33
39
  return void console.log(usage);
40
+ if (command === "dev") {
41
+ process.exitCode = await develop(args);
42
+ return;
43
+ }
34
44
  if (command === "configure") {
35
45
  if (args.length)
36
46
  throw new Error(usage);
@@ -80,5 +90,6 @@ async function main() {
80
90
  }
81
91
  void main().catch((error) => {
82
92
  console.error(error instanceof Error ? error.message : String(error));
83
- process.exitCode = error instanceof ConfigurationCancelled ? error.exitCode : 1;
93
+ process.exitCode =
94
+ error instanceof ConfigurationCancelled ? error.exitCode : 1;
84
95
  });
package/dist/dev.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Runs project development tooling without copying a supervisor into each application. */
2
+ export declare function develop(args: readonly string[]): Promise<number>;
package/dist/dev.js ADDED
@@ -0,0 +1,121 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ /** Runs project development tooling without copying a supervisor into each application. */
7
+ export async function develop(args) {
8
+ const usage = "Usage: nylorun dev [--no-studio] [--no-open]";
9
+ if (new Set(args).size !== args.length ||
10
+ args.some((arg) => !["--no-studio", "--no-open"].includes(arg)))
11
+ throw new Error(usage);
12
+ const port = Number(process.env.PORT ?? "3000");
13
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
14
+ throw new Error("PORT must be an integer between 1 and 65535.");
15
+ const require = createRequire(join(process.cwd(), "package.json"));
16
+ let tsx;
17
+ try {
18
+ tsx = require.resolve("tsx/cli");
19
+ }
20
+ catch {
21
+ throw new Error("Install tsx in your project to use nylorun dev.");
22
+ }
23
+ if (!args.includes("--no-studio")) {
24
+ try {
25
+ require.resolve("@nylorun/studio");
26
+ }
27
+ catch {
28
+ throw new Error("Install @nylorun/studio or use nylorun dev --no-studio.");
29
+ }
30
+ }
31
+ const controller = new AbortController();
32
+ const children = new Set();
33
+ const exits = [];
34
+ let result = 0;
35
+ let force;
36
+ const stop = (code, signal = "SIGTERM") => {
37
+ if (controller.signal.aborted)
38
+ return;
39
+ result = code;
40
+ controller.abort();
41
+ for (const child of children)
42
+ child.kill(signal);
43
+ force = setTimeout(() => {
44
+ for (const child of children)
45
+ child.kill("SIGKILL");
46
+ }, 5_000);
47
+ force.unref();
48
+ };
49
+ const interrupt = () => stop(130, "SIGINT");
50
+ const terminate = () => stop(143);
51
+ process.once("SIGINT", interrupt);
52
+ process.once("SIGTERM", terminate);
53
+ const launch = (argv) => {
54
+ const child = spawn(process.execPath, argv, {
55
+ stdio: "inherit",
56
+ env: { ...process.env, NYLORUN_DEV: "1" },
57
+ });
58
+ children.add(child);
59
+ exits.push(new Promise((resolve) => {
60
+ child.once("error", (error) => {
61
+ console.error(`Could not start development process: ${error.message}`);
62
+ stop(1);
63
+ });
64
+ child.once("close", (code, signal) => {
65
+ children.delete(child);
66
+ stop(code ?? (signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 1));
67
+ resolve();
68
+ });
69
+ }));
70
+ };
71
+ try {
72
+ launch([tsx, "watch", "src/index.ts"]);
73
+ if (!args.includes("--no-studio")) {
74
+ const url = `http://127.0.0.1:${port}/agents/v1/agents`;
75
+ const deadline = Date.now() + 20_000;
76
+ let ready = false;
77
+ while (!controller.signal.aborted && Date.now() < deadline) {
78
+ try {
79
+ const response = await fetch(url, {
80
+ signal: AbortSignal.any([
81
+ controller.signal,
82
+ AbortSignal.timeout(500),
83
+ ]),
84
+ });
85
+ ready = response.ok;
86
+ await response.body?.cancel();
87
+ if (ready)
88
+ break;
89
+ }
90
+ catch {
91
+ /* Watch mode may still be compiling or restarting the app. */
92
+ }
93
+ await delay(50, undefined, { signal: controller.signal }).catch(() => { });
94
+ }
95
+ if (!controller.signal.aborted) {
96
+ if (!ready)
97
+ throw new Error(`Application did not become ready at ${url} within 20 seconds.`);
98
+ launch([
99
+ fileURLToPath(new URL("./cli.js", import.meta.url)),
100
+ "studio",
101
+ "--agent-url",
102
+ `http://localhost:${port}/agents`,
103
+ ...(args.includes("--no-open") ? ["--no-open"] : []),
104
+ ]);
105
+ }
106
+ }
107
+ await Promise.all(exits);
108
+ }
109
+ catch (error) {
110
+ stop(1);
111
+ await Promise.all(exits);
112
+ throw error;
113
+ }
114
+ finally {
115
+ if (force !== undefined)
116
+ clearTimeout(force);
117
+ process.removeListener("SIGINT", interrupt);
118
+ process.removeListener("SIGTERM", terminate);
119
+ }
120
+ return result;
121
+ }
@@ -1,4 +1,4 @@
1
- import { mkdir, writeFile, rename, rm } from "node:fs/promises";
1
+ import { mkdir, writeFile, rename, rm, rmdir } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { join } from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
@@ -98,7 +98,7 @@ export async function configureProvider(options = {}) {
98
98
  }
99
99
  async function save(selection) {
100
100
  signal.throwIfAborted();
101
- const directory = join(root, "config");
101
+ const directory = join(root, ".env");
102
102
  const temporary = join(directory, `.model-${randomUUID()}.json`);
103
103
  try {
104
104
  await mkdir(directory, { recursive: true });
@@ -107,6 +107,16 @@ export async function configureProvider(options = {}) {
107
107
  });
108
108
  signal.throwIfAborted();
109
109
  await rename(temporary, join(directory, "model.json"));
110
+ await rm(join(root, "config", "model.json"), { force: true });
111
+ try {
112
+ await rmdir(join(root, "config"));
113
+ }
114
+ catch (error) {
115
+ if (!(error instanceof Error) ||
116
+ !("code" in error) ||
117
+ !["ENOENT", "ENOTEMPTY", "EEXIST"].includes(String(error.code)))
118
+ throw error;
119
+ }
110
120
  }
111
121
  finally {
112
122
  await rm(temporary, { force: true });
@@ -2,7 +2,18 @@ import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  export function modelSelection(root = process.cwd()) {
4
4
  try {
5
- const value = JSON.parse(readFileSync(join(root, "config", "model.json"), "utf8"));
5
+ let contents;
6
+ try {
7
+ contents = readFileSync(join(root, ".env", "model.json"), "utf8");
8
+ }
9
+ catch (error) {
10
+ if (!(error instanceof Error) ||
11
+ !("code" in error) ||
12
+ error.code !== "ENOENT")
13
+ throw error;
14
+ contents = readFileSync(join(root, "config", "model.json"), "utf8");
15
+ }
16
+ const value = JSON.parse(contents);
6
17
  if (typeof value.provider === "string" &&
7
18
  value.provider &&
8
19
  typeof value.model === "string" &&
@@ -6,7 +6,7 @@ export type RuntimeActor = Readonly<{
6
6
  context?: Record<string, JsonValue>;
7
7
  }>;
8
8
  export type AgentRouterOptions = Readonly<{
9
- /** Public URL prefix of this router, matching the Hono mount path. */
9
+ /** Override the public URL prefix; defaults to the current Hono mount path. */
10
10
  basePath?: string;
11
11
  getActor?: (context: Context) => RuntimeActor | undefined | Promise<RuntimeActor | undefined>;
12
12
  getRequestMetadata?: (context: Context) => Record<string, JsonValue> | undefined | Promise<Record<string, JsonValue> | undefined>;
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Hono } from "hono";
3
+ import { cors } from "hono/cors";
3
4
  import { HTTPException } from "hono/http-exception";
4
5
  import { agUiEvents, sse } from "./ag-ui.js";
5
6
  import { observedPayload } from "./digests.js";
@@ -38,9 +39,34 @@ export class Runtime {
38
39
  const configuredObserver = this.#config.observer;
39
40
  const redact = (value) => scrub(value, projectSecrets());
40
41
  const live = new Map();
41
- let publicPath = (path) => path;
42
42
  const routerOptions = options;
43
43
  const app = new Hono();
44
+ if (process.env.NYLORUN_DEV === "1") {
45
+ app.use("*", cors({
46
+ origin: (origin) => {
47
+ try {
48
+ const url = new URL(origin);
49
+ return ["http:", "https:"].includes(url.protocol) &&
50
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) &&
51
+ url.origin === origin
52
+ ? origin
53
+ : undefined;
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ },
59
+ allowMethods: [
60
+ "GET",
61
+ "HEAD",
62
+ "POST",
63
+ "PUT",
64
+ "PATCH",
65
+ "DELETE",
66
+ "OPTIONS",
67
+ ],
68
+ }));
69
+ }
44
70
  app.onError((error, context) => context.json({ error: String(redact(error.message)) }, error instanceof HTTPException ? error.status : 500));
45
71
  app.use("*", async (context, next) => {
46
72
  await next();
@@ -52,19 +78,22 @@ export class Runtime {
52
78
  });
53
79
  }
54
80
  });
55
- publicPath = (path) => `${normalizeBasePath(routerOptions.basePath)}${path}`;
81
+ // Infer the public mount from this request URL and the local route path.
82
+ // Do not use hono/route basePath: consumers often install a separate `hono`
83
+ // copy, and that helper's match-result Symbol then misses the parent's match.
84
+ const publicPath = (context, routePath, path) => `${normalizeBasePath(routerOptions.basePath ?? inferMountPath(context, routePath))}${path}`;
56
85
  app.get("/v1/agents", (context) => context.json({
57
86
  protocolVersion: 2,
58
87
  agents: agents.map((agent) => ({
59
88
  id: agent.id,
60
- manifestUrl: publicPath(`/${agent.id}/manifest.json`),
89
+ manifestUrl: publicPath(context, "/v1/agents", `/${agent.id}/manifest.json`),
61
90
  })),
62
91
  }));
63
92
  app.get("/:agentId/manifest.json", (context) => {
64
93
  const agent = byId.get(context.req.param("agentId"));
65
94
  return agent === undefined
66
95
  ? context.json({ error: "unknown agent" }, 404)
67
- : context.json(manifest(agent, media !== undefined, publicPath));
96
+ : context.json(manifest(agent, media !== undefined, (path) => publicPath(context, "/:agentId/manifest.json", path)));
68
97
  });
69
98
  app.get("/:agentId/v1/media/:session/:assetId", async (context) => {
70
99
  const agent = requireAgent(context.req.param("agentId"));
@@ -602,3 +631,20 @@ function normalizeBasePath(value) {
602
631
  throw new Error("basePath must start with / and must not end with /.");
603
632
  return value;
604
633
  }
634
+ /** Public mount prefix for this request, derived without hono/route Symbols. */
635
+ function inferMountPath(context, routePath) {
636
+ const pathname = new URL(context.req.url).pathname;
637
+ const suffix = routePath.replace(/:([A-Za-z0-9_]+)/g, (_, key) => {
638
+ const value = context.req.param(key);
639
+ if (value === undefined)
640
+ throw new Error(`Unable to infer Runtime mount path from ${pathname}. Pass basePath matching the Hono mount.`);
641
+ return value;
642
+ });
643
+ if (suffix !== "/" && pathname.endsWith(suffix)) {
644
+ const base = pathname.slice(0, -suffix.length);
645
+ return base === "" ? "/" : base;
646
+ }
647
+ if (pathname === suffix || `${pathname}/` === suffix)
648
+ return "/";
649
+ throw new Error(`Unable to infer Runtime mount path from ${pathname}. Pass basePath matching the Hono mount.`);
650
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.2.1-beta",
3
+ "version": "0.3.0-beta",
4
4
  "description": "Portable agent runtime, Hono protocol router, model providers, and the Nylorun CLI.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",