@pirol/nido 0.2.0 → 0.3.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
@@ -1,50 +1,52 @@
1
1
  # nido
2
2
 
3
- CLI für Agenten-Sandboxes, wie im Konzept
4
- [Agenten-Sandboxes auf OpenShell](../docs/concepts/agent-sandboxes.md)
5
- beschrieben. Man gibt nido einen Ordner, nido lässt darin einen Agenten
6
- arbeiten und holt die Ergebnisse zurück. Jede Sandbox läuft in einer eigenen
7
- Kata-VM.
3
+ Run coding agents and commands in isolated sandboxes. Give nido a folder, it lets an agent work
4
+ on it in a sandbox and brings the results back.
8
5
 
9
- Stand: geschlossene Beta (#686–#688). nido spricht mit `pirol-agents-api`
10
- unter `https://nido.pirol.ai`. Die API prüft den Pirol-Schlüssel, trennt die
11
- Kunden und reicht die Aufrufe an das OpenShell-Gateway weiter. Die Doku für
12
- Kunden steht unter [pirol.ai/docs/sandboxes](https://pirol.ai/docs/sandboxes).
6
+ Every sandbox is its own small virtual machine (Kata Containers) in Frankfurt, run by
7
+ [Pirol](https://pirol.ai).
13
8
 
14
- ## Einrichten
9
+ > **Closed beta.** Free during the beta, for invited accounts only, up to 5 sandboxes per account.
10
+ > Ask for access at [hello@pirol.ai](mailto:hello@pirol.ai).
15
11
 
16
- Voraussetzung: Node 20 oder neuer und ein Pirol-Konto, das für die Beta
17
- freigeschaltet ist (`BETA_ALLOWLIST` in
18
- [deploy-agents-api.sh](../infra/agent-sandbox/deploy-agents-api.sh)).
12
+ Full documentation: [pirol.ai/docs/sandboxes](https://pirol.ai/docs/sandboxes)
13
+
14
+ ## Install
15
+
16
+ Requires Node 20 or newer.
19
17
 
20
18
  ```bash
21
19
  npm install -g @pirol/nido
22
20
  nido login
23
21
  ```
24
22
 
25
- `nido login` läuft wie `kyte login`: Der Browser öffnet pirol.ai, man
26
- bestätigt, und nido legt den Schlüssel in `~/.nido/config.json` ab (nur für
27
- den Nutzer lesbar). Ohne Browser hilft `nido login --manual`. In CI reicht
28
- die Umgebungsvariable `PIROL_API_KEY`. `NIDO_API_URL` zeigt nido auf eine
29
- andere API, etwa eine lokale.
23
+ `nido login` opens your browser. You confirm with your Pirol account, and nido stores a key in
24
+ `~/.nido/config.json` (readable only by you). Without a browser, use `nido login --manual`. In CI,
25
+ set `PIROL_API_KEY` instead.
30
26
 
31
- Aus dem Repo statt von npm:
27
+ ## Run an agent on a folder
32
28
 
33
29
  ```bash
34
- cd nido && npm install && npm run build && npm link
30
+ cd my-project
31
+ nido init # writes nido.json
32
+ nido secret set anthropic # once; asks for the key without echo
33
+ nido run
35
34
  ```
36
35
 
37
- ## Ordner mit `nido.json`
36
+ `nido run`:
38
37
 
39
- ```bash
40
- mkdir survey && cd survey
41
- cp ~/Downloads/results.csv .
42
- nido init # writes nido.json for Claude Code
43
- nido secret set anthropic # once, asks for the key without echo
44
- nido run
45
- ```
38
+ 1. Creates the sandbox if it does not exist yet. The name comes from `name` or the folder name.
39
+ A new sandbox is ready in about 20 seconds.
40
+ 2. Uploads changed files. Unchanged files are not uploaded again; files you deleted locally are
41
+ deleted in the sandbox too.
42
+ 3. Runs the agent and shows each step on one line.
43
+ 4. Brings `output/` back. A file you changed locally is not overwritten; the sandbox version is
44
+ saved next to it, for example as `report.remote.md`.
45
+
46
+ The sandbox keeps running afterwards. The next `nido run` reuses it, `nido run --fresh` starts
47
+ over. `nido pull` fetches `output/` again.
46
48
 
47
- `nido.json`:
49
+ ### nido.json
48
50
 
49
51
  ```json
50
52
  {
@@ -57,40 +59,39 @@ nido run
57
59
  }
58
60
  ```
59
61
 
60
- Was `nido run` macht:
61
-
62
- 1. Sandbox anlegen, falls es sie noch nicht gibt. Der Name kommt aus
63
- `name` oder dem Ordnernamen. Ein neuer Start dauert etwa 20 Sekunden.
64
- 2. Geänderte Dateien hochladen. nido merkt sich die Hashes in
65
- `.nido/state.json`. Unveränderte Dateien gehen nicht noch einmal hoch,
66
- lokal gelöschte verschwinden auch in der Sandbox.
67
- 3. Den Agenten starten. Bei Claude zeigt nido jeden Schritt in einer Zeile.
68
- 4. `output/` zurückholen. Neue Dateien landen im Ordner. Eine Datei, die
69
- man lokal geändert hat, überschreibt nido nicht. Die Fassung aus der
70
- Sandbox heißt dann zum Beispiel `report.remote.md`.
71
-
72
- Die Sandbox bleibt danach stehen. Der nächste `nido run` nutzt sie weiter,
73
- `nido run --fresh` fängt neu an.
74
-
75
- Statt eines Agenten geht auch ein Befehl, das braucht keinen Schlüssel:
62
+ Instead of an agent, run any command. It needs no key:
76
63
 
77
64
  ```json
78
65
  { "command": "python analyze.py", "output": "output/" }
79
66
  ```
80
67
 
81
- Felder:
68
+ | Field | Meaning |
69
+ | --- | --- |
70
+ | `agent` | `claude`; more will follow |
71
+ | `task` | What the agent should do |
72
+ | `command` | Instead of `agent`: a command as a string (runs in bash) or a list |
73
+ | `name` | Sandbox name; defaults to the folder name |
74
+ | `image` | Your own container image; defaults to an image with Claude Code, Codex and OpenCode |
75
+ | `files.exclude` | Patterns like in `.gitignore`; `.git` and `.nido` are always excluded |
76
+ | `secrets` | Names of stored secrets, never values |
77
+ | `network` | Hosts the sandbox may reach on port 443; everything else is blocked |
78
+ | `storage` | Storage volumes, mounted at `/data/<name>` |
79
+ | `output` | Folder that comes back after the run; default `output/` |
82
80
 
83
- | Feld | Bedeutung |
84
- |---|---|
85
- | `agent` | `claude`, weitere folgen |
86
- | `task` | Auftrag an den Agenten |
87
- | `command` | statt `agent`: Befehl als Text (läuft in bash) oder Liste |
88
- | `name` | Name der Sandbox, sonst der Ordnername |
89
- | `image` | eigenes Image, sonst das OpenShell-Community-Image mit Claude Code, Codex und OpenCode |
90
- | `files.exclude` | Muster wie in `.gitignore`. `.git` und `.nido` bleiben immer draußen |
91
- | `secrets` | Namen von Secrets, nie die Werte |
92
- | `network` | Hosts, die die Sandbox auf Port 443 erreichen darf. Sonst ist das Netz zu |
93
- | `output` | Ordner, den nido zurückholt, Standard `output/` |
81
+ ## Storage
82
+
83
+ Keep large data in storage, independently of sandboxes:
84
+
85
+ ```bash
86
+ nido storage put ./data my-data # upload straight to Cloud Storage in Frankfurt
87
+ nido storage ls # volumes with file count and size
88
+ nido storage ls my-data # files in one volume
89
+ nido storage rm my-data # only once no sandbox uses it
90
+ ```
91
+
92
+ Add `"storage": ["my-data"]` to `nido.json` (or `nido create shop --storage my-data`), and the
93
+ sandbox reads and writes the data at `/data/my-data`, without copying. Volumes are attached when a
94
+ sandbox is created; after changing `storage`, run `nido run --fresh`.
94
95
 
95
96
  ## Secrets
96
97
 
@@ -102,14 +103,10 @@ nido secret ls
102
103
  nido secret rm github
103
104
  ```
104
105
 
105
- Der Wert geht per HTTPS an die API und von dort über eine
106
- Umgebungsvariable an das Gateway, nie als Argument. Lesen kann ihn danach
107
- niemand mehr, auch der Kunde nicht.
108
- In der Sandbox steht nur ein Platzhalter. Der Proxy von OpenShell setzt den
109
- echten Wert erst beim Aufruf ein, und nur für die Hosts des Dienstes, etwa
110
- `api.anthropic.com`. Andere Namen kennt nido noch nicht.
106
+ A secret is stored once and can never be read again, not even by you. Inside the sandbox the agent
107
+ only sees a placeholder; the real value is added on the way to the provider's API.
111
108
 
112
- ## Weitere Befehle
109
+ ## More commands
113
110
 
114
111
  ```bash
115
112
  nido create shop --secret anthropic --network pypi.org
@@ -120,26 +117,27 @@ nido cp shop:/sandbox/work/output ./out
120
117
  nido logs shop
121
118
  nido stop shop && nido start shop
122
119
  nido rm shop
120
+ nido whoami
121
+ nido logout
123
122
  ```
124
123
 
125
- ## Entwicklung
124
+ Paths inside a sandbox must be below `/sandbox`.
126
125
 
127
- ```bash
128
- npm test # unit tests
129
- npm run build # dist/
130
- ```
126
+ ## Limits in the beta
131
127
 
132
- Die Regeln für Hoch- und Herunterladen stecken in `src/sync.ts` und sind
133
- reine Funktionen. Die Aufrufe an die API liegen in `src/api.ts`, die Anmeldung
134
- in `src/login.ts`. Mit OpenShell spricht nur noch
135
- [agents-api](../agents-api).
128
+ - Up to 5 sandboxes per account, and 10 storage volumes with 100 GB in total.
129
+ - No interactive shell yet; use `nido exec`.
130
+ - The agent runs in the foreground. If the connection drops, `nido pull` fetches what is already
131
+ in `output/`.
132
+ - No backups or templates of a sandbox yet. Keep anything important in `output/` or in storage.
136
133
 
137
- Veröffentlichen: `npm publish` im Ordner `nido` (baut vorher neu).
134
+ ## Development
138
135
 
139
- ## Grenzen
136
+ ```bash
137
+ cd nido && npm install && npm run build && npm link
138
+ npm test
139
+ ```
140
140
 
141
- - Höchstens 5 Sandboxes pro Konto in der Beta.
142
- - `nido shell` fehlt noch. Stattdessen `nido exec`.
143
- - Der Agent läuft im Vordergrund. Bricht die Verbindung ab, holt
144
- `nido pull` das, was schon in `output/` liegt.
145
- - Keine dauerhaften Arbeitsbereiche und kein Backup (#695).
141
+ `NIDO_API_URL` points nido at another API, for example a local agents-api. Upload and
142
+ download rules live in `src/sync.ts` as pure functions, API calls in `src/api.ts`, login in
143
+ `src/login.ts`. Publish with `npm publish` in this folder; it builds and tests first.
package/dist/api.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Client for pirol-agents-api. Files travel as tar.gz streams, command
2
2
  // output comes back as NDJSON while the command runs.
3
3
  import { spawn } from "node:child_process";
4
+ import { createReadStream } from "node:fs";
5
+ import { request } from "node:https";
4
6
  import { Readable } from "node:stream";
5
7
  import { loadConfig } from "./config.js";
6
8
  export class ApiError extends Error {
@@ -152,4 +154,37 @@ export class Api {
152
154
  removeSecret(name) {
153
155
  return this.json("DELETE", `/v1/secrets/${encodeURIComponent(name)}`);
154
156
  }
157
+ storageList() {
158
+ return this.json("GET", "/v1/storage");
159
+ }
160
+ storageFiles(name) {
161
+ return this.json("GET", `/v1/storage/${encodeURIComponent(name)}`);
162
+ }
163
+ async storageCreate(name) {
164
+ return (await this.json("PUT", `/v1/storage/${encodeURIComponent(name)}`)).result;
165
+ }
166
+ /** One upload URL per file; each takes exactly the given number of bytes. */
167
+ async storageUploads(name, files) {
168
+ return (await this.json("POST", `/v1/storage/${encodeURIComponent(name)}/uploads`, { files })).uploads;
169
+ }
170
+ storageRemove(name) {
171
+ return this.json("DELETE", `/v1/storage/${encodeURIComponent(name)}`);
172
+ }
173
+ }
174
+ /** Sends a local file to an upload URL from storageUploads (straight to Cloud Storage). */
175
+ export function putFile(url, file, size) {
176
+ return new Promise((resolve, reject) => {
177
+ const req = request(url, { method: "PUT", headers: { "content-length": size } }, (res) => {
178
+ let body = "";
179
+ res.on("data", (d) => (body += d.toString("utf8")));
180
+ res.on("end", () => res.statusCode === 200 || res.statusCode === 201
181
+ ? resolve()
182
+ : reject(new Error(`upload failed: HTTP ${res.statusCode} ${body.slice(0, 200)}`)));
183
+ });
184
+ req.on("error", reject);
185
+ if (size === 0)
186
+ req.end();
187
+ else
188
+ createReadStream(file).on("error", reject).pipe(req);
189
+ });
155
190
  }
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { readFileSync, realpathSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { copy, createSandbox, exec, init, list, login, logout, pull, runFolder, secretList, secretRemove, secretSet, simple, whoami, } from "./commands.js";
5
+ import { storageLs, storagePut, storageRm } from "./storage.js";
5
6
  const HELP = `nido — run agents in isolated sandboxes (closed beta)
6
7
 
7
8
  Start
@@ -14,13 +15,18 @@ Folder workflow
14
15
  nido pull bring output/ back again
15
16
 
16
17
  Sandboxes
17
- nido create <name> [--image <ref>] [--secret <name>]… [--network <host>]…
18
+ nido create <name> [--image <ref>] [--secret <name>]… [--network <host>]… [--storage <name>]…
18
19
  nido ls list your sandboxes
19
20
  nido exec <name> -- <cmd>… run a command
20
21
  nido cp <src> <dst> copy files; write the sandbox side as <name>:<path>
21
22
  nido logs <name> sandbox logs
22
23
  nido stop|start|rm <name>
23
24
 
25
+ Storage (lives on without sandboxes, mounted at /data/<name>)
26
+ nido storage put <folder|file> <name> upload, straight to Cloud Storage
27
+ nido storage ls [name]
28
+ nido storage rm <name>
29
+
24
30
  Secrets (stored once, never readable again)
25
31
  nido secret set <name> [--from-gh] anthropic, openai, github
26
32
  nido secret ls
@@ -69,6 +75,7 @@ async function main(argv) {
69
75
  image: flags.get("image")?.[0],
70
76
  secrets: flags.get("secret") ?? [],
71
77
  network: flags.get("network") ?? [],
78
+ storage: flags.get("storage") ?? [],
72
79
  });
73
80
  return 0;
74
81
  case "ls":
@@ -93,6 +100,8 @@ async function main(argv) {
93
100
  return 0;
94
101
  case "secret":
95
102
  return secret(args, flags);
103
+ case "storage":
104
+ return storage(args);
96
105
  default:
97
106
  throw new Error(`Unknown command "${cmd}". See: nido help`);
98
107
  }
@@ -109,7 +118,19 @@ async function secret(args, flags) {
109
118
  throw new Error("Usage: nido secret set <name> | nido secret ls | nido secret rm <name>");
110
119
  return 0;
111
120
  }
112
- const VALUE_FLAGS = new Set(["image", "secret", "network"]);
121
+ async function storage(args) {
122
+ const [sub, a, b] = args;
123
+ if (sub === "put" && a && b)
124
+ await storagePut(a, b);
125
+ else if (sub === "ls")
126
+ await storageLs(a);
127
+ else if (sub === "rm" && a)
128
+ await storageRm(a);
129
+ else
130
+ throw new Error("Usage: nido storage put <folder|file> <name> | nido storage ls [name] | nido storage rm <name>");
131
+ return 0;
132
+ }
133
+ const VALUE_FLAGS = new Set(["image", "secret", "network", "storage"]);
113
134
  export function parse(argv) {
114
135
  const flags = new Map();
115
136
  const args = [];
package/dist/commands.js CHANGED
@@ -164,7 +164,13 @@ async function ensureSandbox(api, m, fresh) {
164
164
  }
165
165
  if (!info) {
166
166
  say(`Creating sandbox ${m.name}…`);
167
- const created = await api.create({ name: m.name, image: m.imageSet ? m.image : undefined, secrets: m.secrets, network: m.network });
167
+ const created = await api.create({
168
+ name: m.name,
169
+ image: m.imageSet ? m.image : undefined,
170
+ secrets: m.secrets,
171
+ network: m.network,
172
+ storage: m.storage,
173
+ });
168
174
  say(`Sandbox ${m.name} ready in ${(created.ready_ms / 1000).toFixed(1)} s`);
169
175
  return true;
170
176
  }
package/dist/manifest.js CHANGED
@@ -25,7 +25,7 @@ export function parseManifest(raw, folderName) {
25
25
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
26
26
  throw new Error(`${MANIFEST} must be an object`);
27
27
  const m = raw;
28
- const known = ["name", "agent", "task", "command", "image", "files", "secrets", "network", "output"];
28
+ const known = ["name", "agent", "task", "command", "image", "files", "secrets", "network", "storage", "output"];
29
29
  const unknown = Object.keys(m).filter((key) => !known.includes(key) && !key.startsWith("$"));
30
30
  if (unknown.length)
31
31
  throw new Error(`${MANIFEST}: unknown field ${unknown.map((k) => `"${k}"`).join(", ")}`);
@@ -65,6 +65,11 @@ export function parseManifest(raw, folderName) {
65
65
  exclude,
66
66
  secrets: stringList(m, "secrets"),
67
67
  network: stringList(m, "network"),
68
+ storage: stringList(m, "storage").map((name) => {
69
+ if (!isVolumeName(name))
70
+ throw new Error(`${MANIFEST}: "${name}" in "storage" is not a valid name`);
71
+ return name;
72
+ }),
68
73
  output,
69
74
  };
70
75
  }
@@ -78,6 +83,10 @@ export function sandboxName(input) {
78
83
  .replace(/-+$/, "");
79
84
  return name || "nest";
80
85
  }
86
+ /** Storage names, like sandbox names: lowercase letters, digits and dashes, at most 40. */
87
+ export function isVolumeName(name) {
88
+ return /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/.test(name);
89
+ }
81
90
  function optionalString(m, key) {
82
91
  const value = m[key];
83
92
  if (value === undefined)
@@ -0,0 +1,115 @@
1
+ // nido storage: volumes in Cloud Storage that live independently of
2
+ // sandboxes. Files go straight to Cloud Storage; the API only hands out an
3
+ // upload URL per file.
4
+ import { existsSync, statSync } from "node:fs";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
+ import { Api, putFile } from "./api.js";
7
+ import { excludeMatcher, listFiles } from "./files.js";
8
+ import { isVolumeName } from "./manifest.js";
9
+ const say = (text) => void process.stderr.write(text + "\n");
10
+ const BATCH = 500;
11
+ const PARALLEL = 8;
12
+ export function checkVolumeName(name) {
13
+ if (!isVolumeName(name))
14
+ throw new Error(`"${name}" is not a valid storage name: lowercase letters, digits and dashes, at most 40 characters`);
15
+ return name;
16
+ }
17
+ /** Local files to upload: a folder's contents, or one file. Paths use "/". */
18
+ export function localFiles(source) {
19
+ const full = resolve(source);
20
+ if (!existsSync(full))
21
+ throw new Error(`${source} does not exist`);
22
+ if (statSync(full).isFile())
23
+ return { root: dirname(full), files: [{ path: basename(full), size: statSync(full).size }] };
24
+ const paths = listFiles(full, excludeMatcher([]));
25
+ return { root: full, files: paths.map((path) => ({ path, size: statSync(join(full, path)).size })) };
26
+ }
27
+ /** nido storage put <folder|file> <name> */
28
+ export async function storagePut(source, name) {
29
+ checkVolumeName(name);
30
+ const { root, files } = localFiles(source);
31
+ if (!files.length)
32
+ throw new Error(`${source} has no files`);
33
+ const api = Api.fromConfig();
34
+ if ((await api.storageCreate(name)) === "created")
35
+ say(`Created storage ${name}`);
36
+ const total = files.reduce((sum, f) => sum + f.size, 0);
37
+ say(`Uploading ${files.length} file${files.length === 1 ? "" : "s"} (${size(total)}) to ${name}…`);
38
+ let done = 0;
39
+ let sent = 0;
40
+ const started = Date.now();
41
+ const progress = () => {
42
+ if (process.stderr.isTTY)
43
+ process.stderr.write(`\r ${done}/${files.length} files, ${size(sent)} of ${size(total)} `);
44
+ };
45
+ for (let i = 0; i < files.length; i += BATCH) {
46
+ const batch = files.slice(i, i + BATCH);
47
+ const queue = await api.storageUploads(name, batch);
48
+ const bySize = new Map(batch.map((f) => [f.path, f.size]));
49
+ const worker = async () => {
50
+ for (let item = queue.shift(); item; item = queue.shift()) {
51
+ const bytes = bySize.get(item.path);
52
+ await uploadWithRetry(api, name, item, join(root, item.path), bytes);
53
+ done += 1;
54
+ sent += bytes;
55
+ progress();
56
+ }
57
+ };
58
+ await Promise.all(Array.from({ length: PARALLEL }, worker));
59
+ }
60
+ if (process.stderr.isTTY)
61
+ process.stderr.write("\n");
62
+ const seconds = (Date.now() - started) / 1000;
63
+ say(`Done in ${seconds.toFixed(1)} s. Sandboxes see it at /data/${name} with "storage": ["${name}"] in nido.json`);
64
+ }
65
+ async function uploadWithRetry(api, name, item, file, bytes) {
66
+ let url = item.url;
67
+ for (let attempt = 1;; attempt++) {
68
+ try {
69
+ return await putFile(url, file, bytes);
70
+ }
71
+ catch (err) {
72
+ if (attempt === 3)
73
+ throw new Error(`${item.path}: ${err.message}`);
74
+ // A failed session cannot take the whole file again; ask for a new one.
75
+ url = (await api.storageUploads(name, [{ path: item.path, size: bytes }]))[0].url;
76
+ }
77
+ }
78
+ }
79
+ /** nido storage ls [name] */
80
+ export async function storageLs(name) {
81
+ const api = Api.fromConfig();
82
+ if (name) {
83
+ const volume = await api.storageFiles(checkVolumeName(name));
84
+ for (const f of volume.files)
85
+ process.stdout.write(`${size(f.size).padStart(10)} ${f.path}\n`);
86
+ if (volume.total_files > volume.files.length)
87
+ say(`… ${volume.total_files - volume.files.length} more files`);
88
+ say(`${volume.total_files} files, ${size(volume.bytes)}`);
89
+ return;
90
+ }
91
+ const { volumes, limit_bytes } = await api.storageList();
92
+ if (!volumes.length)
93
+ return say("No storage yet. Upload a folder with: nido storage put ./data my-data");
94
+ const width = Math.max(4, ...volumes.map((v) => v.name.length));
95
+ process.stdout.write(`${"NAME".padEnd(width)} ${"FILES".padStart(7)} ${"SIZE".padStart(10)}\n`);
96
+ for (const v of volumes)
97
+ process.stdout.write(`${v.name.padEnd(width)} ${String(v.files).padStart(7)} ${size(v.bytes).padStart(10)}\n`);
98
+ const used = volumes.reduce((sum, v) => sum + v.bytes, 0);
99
+ say(`${size(used)} of ${size(limit_bytes)} used`);
100
+ }
101
+ /** nido storage rm <name> */
102
+ export async function storageRm(name) {
103
+ await Api.fromConfig().storageRemove(checkVolumeName(name));
104
+ say(`Removed storage ${name}`);
105
+ }
106
+ export function size(bytes) {
107
+ const units = ["B", "KB", "MB", "GB", "TB"];
108
+ let value = bytes;
109
+ let unit = 0;
110
+ while (value >= 1024 && unit < units.length - 1) {
111
+ value /= 1024;
112
+ unit++;
113
+ }
114
+ return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pirol/nido",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Run agents in isolated sandboxes. Give it a folder, get results back.",
5
5
  "type": "module",
6
6
  "bin": {