Mike0021 commited on
Commit
df3a27a
·
verified ·
1 Parent(s): 9e6b564

Runtime image for Pi Web workspace machines

Browse files
Dockerfile ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Runtime image for Pi Web on HF Sandboxes. Every workspace machine is an HF Sandbox (a Job
2
+ # in its user's own account) started from this image, so the Space is public: Jobs in any
3
+ # namespace can pull it. It holds no secret. The Space itself only serves a short page.
4
+ FROM node:22-bookworm
5
+
6
+ # Tools for the coding agent, and zstd for the workspace archives.
7
+ RUN apt-get update \
8
+ && apt-get install -y --no-install-recommends \
9
+ python3-venv python3-pip python-is-python3 zstd ripgrep fd-find jq less vim-tiny procps htop git-lfs unzip zip rsync \
10
+ && ln -sf /usr/bin/fdfind /usr/local/bin/fd \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ ARG UV_VERSION=0.12.21
14
+ RUN curl -LsSf "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-unknown-linux-gnu.tar.gz" \
15
+ | tar -xz --strip-components=1 -C /usr/local/bin uv-x86_64-unknown-linux-gnu/uv uv-x86_64-unknown-linux-gnu/uvx
16
+
17
+ # The Hugging Face CLI and its agent skills, for every project: pi reads ~/.agents/skills.
18
+ # AWS SageMaker skills are left out (no AWS credentials in a sandbox).
19
+ ARG HF_CLI_VERSION=2.1.1
20
+ ARG HF_SKILLS_EXCLUDE="^hf-cloud-"
21
+ RUN UV_TOOL_DIR=/opt/uv-tools UV_TOOL_BIN_DIR=/usr/local/bin uv tool install --python /usr/bin/python3 "huggingface_hub==${HF_CLI_VERSION}" \
22
+ && hf skills add --global --force \
23
+ && for skill in $(hf skills list -q 2>/dev/null | grep -vx 'hf-cli' | grep -Ev "${HF_SKILLS_EXCLUDE}"); do hf skills add "$skill" --global --force; done \
24
+ && rm -rf /root/.claude /root/.cache/huggingface \
25
+ && ls /root/.agents/skills
26
+
27
+ # git authenticates to huggingface.co with the token lent by the sign-in, if any.
28
+ COPY sandbox/git-credential-huggingface /usr/local/bin/git-credential-huggingface
29
+ RUN chmod 755 /usr/local/bin/git-credential-huggingface \
30
+ && git config --system credential.https://huggingface.co.helper /usr/local/bin/git-credential-huggingface \
31
+ && git config --system credential.https://hf.co.helper /usr/local/bin/git-credential-huggingface
32
+
33
+ # Pi Web, unmodified, from its npm release (prebuilt Next.js app + pi SDK).
34
+ ARG PI_WEB_VERSION=0.12.1
35
+ RUN npm install -g --no-audit --no-fund "@agegr/pi-web@${PI_WEB_VERSION}" && npm cache clean --force
36
+
37
+ COPY sandbox /opt/hfpi
38
+ COPY www /opt/hfpi/www
39
+ RUN chmod -R a+rX /opt/hfpi
40
+
41
+ EXPOSE 7860
42
+ CMD ["python3", "-m", "http.server", "7860", "--directory", "/opt/hfpi/www"]
README.md CHANGED
@@ -1,10 +1,29 @@
1
  ---
2
- title: Pi Web Runtime
3
- emoji: 🏃
4
- colorFrom: purple
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Pi Web runtime
3
+ emoji: 🥧
4
+ colorFrom: yellow
5
+ colorTo: gray
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ short_description: Sandbox image for Pi Web on Hugging Face Sandboxes
10
  ---
11
 
12
+ # Pi Web runtime image
13
+
14
+ The image every workspace machine of **Pi Web on HF Sandboxes** runs. A machine is an HF Sandbox, an
15
+ [HF Job](https://huggingface.co/docs/huggingface_hub/guides/jobs) started in the signed-in user's own account from
16
+ `hf.co/spaces/Mike0021/pi-web-runtime`. That is why this Space is public: Jobs in any namespace must be able to pull
17
+ it. It contains no secret.
18
+
19
+ What is inside:
20
+
21
+ - [Pi Web](https://github.com/agegr/pi-web) 0.12.1 (unmodified npm release) and the pi coding agent
22
+ - Node 22, Python 3, uv, git, git-lfs, build tools, ripgrep, fd, jq, zstd
23
+ - the `hf` CLI (`huggingface_hub` 2.1.1) and the Hugging Face agent skills in `~/.agents/skills`
24
+ - `sandbox/hfpi.mjs`: the supervisor that restores the user's projects and sessions from their bucket, runs Pi Web,
25
+ saves state while it changes, and stops the machine when idle
26
+ - `sandbox/git-credential-huggingface`: git authenticates to huggingface.co with the token the user lent at sign-in
27
+ - `sandbox/AGENTS.md`: what the agent is told about this environment
28
+
29
+ The Space app itself only serves a short page; the gateway lives in a separate Space.
sandbox/AGENTS.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment: Hugging Face Sandbox
2
+
3
+ You run inside a disposable Hugging Face Sandbox (a container on HF Jobs, in the signed-in user's own account) driven from Pi Web in a browser.
4
+
5
+ - You are `root` on Debian 12 with Node 22, npm, Python 3 (`python`), uv, git, git-lfs, gcc/make, ripgrep, fd, jq and zstd. You can `apt-get install` more, but system packages are gone after a restart.
6
+ - Hardware: see `$ACCELERATOR`, `$CPU_CORES` and `$MEMORY`. Internet access is available.
7
+ - Projects live in `/workspace/<name>`. Only `/workspace` is saved: every project is archived to a private bucket in the user's account every couple of minutes while it changes, and when the machine stops. Anything outside `/workspace` (global installs, `~/.cache`, `/tmp`) is lost on restart.
8
+ - Dependency and build directories are not saved: `node_modules`, Python virtualenvs, `__pycache__`, `.next` and folders with a `CACHEDIR.TAG` (such as Rust `target/`). After a restart, reinstall before running anything (`npm ci`, `uv sync`, `pip install -r requirements.txt`, ...).
9
+ - Nobody can open a server you start here in their browser. To check a web app, start it in the background (`nohup ... > /tmp/app.log 2>&1 &`) and query it with `curl`. To show the user a running app, deploy it to a Hugging Face Space.
10
+ - The machine stops after a period without user activity; an agent turn in progress keeps it alive.
11
+
12
+ ## Hugging Face
13
+
14
+ - The Hugging Face CLI `hf` is installed, and Hugging Face skills are in `~/.agents/skills` (`hf-cli`, `huggingface-spaces`, `huggingface-datasets`, `huggingface-llm-trainer`, `huggingface-gradio`, ...). Use them for anything on the Hub.
15
+ - When the user granted agent access at sign-in, `~/.cache/huggingface/token` holds their scoped, expiring token: `hf`, `huggingface_hub` and `git` (clone and push on huggingface.co) act as that user, within the permissions they granted. Check with `hf auth whoami`. If there is no token, say so instead of asking for one. The token is renewed in place; read the file when you need it rather than copying it.
16
+ - Never print, log, copy or commit the token. For tools that only read `HF_TOKEN`, pass it for that command: `HF_TOKEN=$(cat ~/.cache/huggingface/token) node script.mjs`.
17
+ - What you do with it is real and billed to the user: Jobs, Inference Providers calls, new repos, pushes, pull requests. Ask before starting paid hardware or long Jobs, deleting or overwriting anything on the Hub, or publishing to a public repo; prefer a pull request over pushing to someone's main branch.
18
+ - There is no GitHub token: commit locally; the user can download a patch from the dashboard.
sandbox/git-credential-huggingface ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ # Git credential helper for huggingface.co (set in /etc/gitconfig): answers with the token
3
+ # the Space lent to the agent through Sign in with Hugging Face, if there is one, so the
4
+ # token never ends up in a remote URL or in .git/config.
5
+ cat >/dev/null
6
+ [ "$1" = "get" ] || exit 0
7
+ dir="${HF_HOME:-$HOME/.cache/huggingface}"
8
+ [ -s "$dir/token" ] || exit 0
9
+ user=$(sed -n 's/.*"user": *"\([^"]*\)".*/\1/p' "$dir/hfpi-identity.json" 2>/dev/null)
10
+ printf 'username=%s\npassword=%s\n' "${user:-hf}" "$(cat "$dir/token")"
sandbox/hfpi.mjs ADDED
@@ -0,0 +1,708 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ // hfpi: supervisor and state manager for Pi Web inside a Hugging Face Sandbox.
3
+ //
4
+ // The sandbox is disposable; the bucket mounted at $HFPI_PERSIST is not. Working
5
+ // trees live on fast local disk under $HFPI_WORKSPACE and are saved as one
6
+ // tar.zst per project (a FUSE bucket mount is too slow for git and drops modes and
7
+ // symlinks, a single archive keeps both). The pi agent directory (sessions,
8
+ // settings, models) is saved the same way, more often.
9
+ //
10
+ // hfpi run boot: restore, configure pi, start Pi Web, keep state saved
11
+ // hfpi snapshot [--name N] [--agent] [--force]
12
+ // hfpi import --name N (--git URL [--ref R] | --tar PATH | --empty) [--source JSON]
13
+ // hfpi delete --name N
14
+ // hfpi changes --name N files changed since the import, for a Hub PR
15
+ // hfpi patch --name N the same changes as a git patch
16
+ // hfpi status JSON for the gateway
17
+ // hfpi heartbeat user activity seen by the gateway
18
+ // hfpi stop final snapshot, then stop Pi Web and the supervisor
19
+
20
+ import { spawn, spawnSync } from "node:child_process";
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+
25
+ const WORKSPACE = process.env.HFPI_WORKSPACE || "/workspace";
26
+ const PERSIST = process.env.HFPI_PERSIST || "/persist";
27
+ const HOME = os.homedir();
28
+ const AGENT_DIR = process.env.PI_CODING_AGENT_DIR || path.join(HOME, ".pi", "agent");
29
+ const RUN_DIR = process.env.HFPI_RUN_DIR || "/tmp/hfpi";
30
+ const PORT = Number(process.env.HFPI_PIWEB_PORT || 30141);
31
+ const PROJECTS_DIR = path.join(PERSIST, "projects");
32
+ const AGENT_ARCHIVE = path.join(PERSIST, "agent", "agent.tar.zst");
33
+ const MACHINE_DIR = path.join(PERSIST, "machine");
34
+ const IDLE_MINUTES = Number(process.env.HFPI_IDLE_MINUTES ?? 30);
35
+ const MAX_LIFETIME_MINUTES = Number(process.env.HFPI_MAX_LIFETIME_MINUTES ?? 23 * 60 + 40);
36
+ const AGENT_SAVE_SECONDS = Number(process.env.HFPI_AGENT_SAVE_SECONDS ?? 20);
37
+ const PROJECT_SAVE_SECONDS = Number(process.env.HFPI_PROJECT_SAVE_SECONDS ?? 120);
38
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
39
+ const MANAGED_MARK = "<!-- managed by hfpi: delete this line to keep your own edits -->";
40
+
41
+ // Regenerable directories are not saved: they make archives 20x larger and slower.
42
+ // Python virtualenvs are found by their pyvenv.cfg, Rust/pytest caches by CACHEDIR.TAG.
43
+ const PROJECT_EXCLUDES = [
44
+ "node_modules", "__pycache__", ".next", ".nuxt", ".turbo", ".svelte-kit",
45
+ ".parcel-cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".gradle",
46
+ ...(process.env.HFPI_EXTRA_EXCLUDES || "").split(",").map((s) => s.trim()).filter(Boolean),
47
+ ];
48
+ const TAR_EXCLUDE_ARGS = [
49
+ ...PROJECT_EXCLUDES.map((e) => `--exclude=${e}`),
50
+ "--exclude-caches-all",
51
+ "--exclude-tag-all=pyvenv.cfg",
52
+ ];
53
+ const AGENT_EXCLUDES = ["./bin", "./tmp"];
54
+
55
+ // ---------------------------------------------------------------- helpers
56
+
57
+ function log(...args) {
58
+ console.error(`[hfpi ${new Date().toISOString()}]`, ...args);
59
+ }
60
+
61
+ function run(cmd, args, opts = {}) {
62
+ const res = spawnSync(cmd, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, ...opts });
63
+ if (res.error) throw res.error;
64
+ if (res.status !== 0 && !opts.allowFail) {
65
+ const err = new Error(`${cmd} ${args.join(" ")} exited ${res.status}: ${(res.stderr || res.stdout || "").trim().slice(-2000)}`);
66
+ err.result = res;
67
+ throw err;
68
+ }
69
+ return res;
70
+ }
71
+
72
+ function exists(p) {
73
+ try {
74
+ fs.lstatSync(p);
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ function readJson(p, fallback = null) {
82
+ try {
83
+ return JSON.parse(fs.readFileSync(p, "utf8"));
84
+ } catch {
85
+ return fallback;
86
+ }
87
+ }
88
+
89
+ function writeJson(p, data) {
90
+ fs.mkdirSync(path.dirname(p), { recursive: true });
91
+ const tmp = `${p}.tmp-${process.pid}`;
92
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
93
+ fs.renameSync(tmp, p);
94
+ }
95
+
96
+ function touch(p) {
97
+ fs.mkdirSync(path.dirname(p), { recursive: true });
98
+ const now = new Date();
99
+ try {
100
+ fs.utimesSync(p, now, now);
101
+ } catch {
102
+ fs.writeFileSync(p, "");
103
+ }
104
+ }
105
+
106
+ function mtimeMs(p) {
107
+ try {
108
+ return fs.statSync(p).mtimeMs;
109
+ } catch {
110
+ return 0;
111
+ }
112
+ }
113
+
114
+ function checkName(name) {
115
+ if (!name || !NAME_RE.test(name)) throw new Error(`invalid project name ${JSON.stringify(name)}: use letters, digits, '.', '_' or '-'`);
116
+ return name;
117
+ }
118
+
119
+ function parseArgs(argv) {
120
+ const out = { _: [] };
121
+ for (let i = 0; i < argv.length; i++) {
122
+ const a = argv[i];
123
+ if (a.startsWith("--")) {
124
+ const key = a.slice(2);
125
+ const next = argv[i + 1];
126
+ if (next === undefined || next.startsWith("--")) out[key] = true;
127
+ else {
128
+ out[key] = next;
129
+ i++;
130
+ }
131
+ } else out._.push(a);
132
+ }
133
+ return out;
134
+ }
135
+
136
+ // A mkdir lock, so the supervisor loop and gateway-triggered commands never write
137
+ // the same archive at once. A lock whose owner died is taken over.
138
+ function withLock(key, fn) {
139
+ const dir = path.join(RUN_DIR, "locks", key);
140
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
141
+ const deadline = Date.now() + 10 * 60 * 1000;
142
+ for (;;) {
143
+ try {
144
+ fs.mkdirSync(dir);
145
+ fs.writeFileSync(path.join(dir, "pid"), String(process.pid));
146
+ break;
147
+ } catch (e) {
148
+ if (e.code !== "EEXIST") throw e;
149
+ let owner = 0;
150
+ try {
151
+ owner = Number(fs.readFileSync(path.join(dir, "pid"), "utf8")) || 0;
152
+ } catch {}
153
+ let alive = false;
154
+ if (owner) {
155
+ try {
156
+ process.kill(owner, 0);
157
+ alive = true;
158
+ } catch {}
159
+ }
160
+ if (!alive && Date.now() - mtimeMs(dir) > 2000) {
161
+ fs.rmSync(dir, { recursive: true, force: true });
162
+ continue;
163
+ }
164
+ if (Date.now() > deadline) throw new Error(`timed out waiting for lock ${key}`);
165
+ spawnSync("sleep", ["0.2"]);
166
+ }
167
+ }
168
+ try {
169
+ return fn();
170
+ } finally {
171
+ fs.rmSync(dir, { recursive: true, force: true });
172
+ }
173
+ }
174
+
175
+ // True when anything under dir (minus excluded directories) is newer than marker.
176
+ // Directory mtimes change on create/delete/rename, so removals are caught too.
177
+ function changedSince(dir, marker, excludes) {
178
+ if (!exists(marker)) return true;
179
+ const prune = excludes.flatMap((e, i) => (i ? ["-o"] : []).concat(e.startsWith("./") ? ["-path", path.join(dir, e.slice(2))] : ["-name", e]));
180
+ const args = [dir, "(", ...prune, ")", "-prune", "-o", "-newer", marker, "-print", "-quit"];
181
+ const res = run("find", args, { allowFail: true });
182
+ return Boolean(res.stdout.trim());
183
+ }
184
+
185
+ function listProjectDirs() {
186
+ if (!exists(WORKSPACE)) return [];
187
+ return fs.readdirSync(WORKSPACE, { withFileTypes: true })
188
+ .filter((d) => d.isDirectory() && NAME_RE.test(d.name))
189
+ .map((d) => d.name)
190
+ .sort();
191
+ }
192
+
193
+ function projectMetaPath(name) {
194
+ return path.join(PROJECTS_DIR, name, "meta.json");
195
+ }
196
+
197
+ // Folders nobody imported: Pi Web's default directory, or one the agent created.
198
+ function defaultSource(name) {
199
+ return { kind: name === "pi-cwd" ? "default-dir" : "local" };
200
+ }
201
+
202
+ function projectArchivePath(name) {
203
+ return path.join(PROJECTS_DIR, name, "snapshot.tar.zst");
204
+ }
205
+
206
+ function gitHead(dir) {
207
+ const res = run("git", ["-C", dir, "rev-parse", "HEAD"], { allowFail: true });
208
+ return res.status === 0 ? res.stdout.trim() : null;
209
+ }
210
+
211
+ function gitBranch(dir) {
212
+ const res = run("git", ["-C", dir, "rev-parse", "--abbrev-ref", "HEAD"], { allowFail: true });
213
+ return res.status === 0 ? res.stdout.trim() : null;
214
+ }
215
+
216
+ // ---------------------------------------------------------------- snapshots
217
+
218
+ // Archive to a temporary file next to the target, then rename: a sandbox killed
219
+ // mid-upload leaves the previous archive intact.
220
+ function writeArchive(archive, tarArgs) {
221
+ fs.mkdirSync(path.dirname(archive), { recursive: true });
222
+ const tmp = `${archive}.partial`;
223
+ run("tar", ["--zstd", "-cf", tmp, ...tarArgs]);
224
+ fs.renameSync(tmp, archive);
225
+ return fs.statSync(archive).size;
226
+ }
227
+
228
+ function snapshotProject(name, { force = false } = {}) {
229
+ checkName(name);
230
+ const dir = path.join(WORKSPACE, name);
231
+ if (!exists(dir)) return { name, skipped: "missing" };
232
+ const marker = path.join(RUN_DIR, "markers", `project-${name}`);
233
+ if (!force && !changedSince(dir, marker, PROJECT_EXCLUDES)) return { name, skipped: "unchanged" };
234
+ return withLock(`project-${name}`, () => {
235
+ const started = new Date();
236
+ const pending = `${marker}.pending`;
237
+ touch(pending); // taken before tar reads anything: edits made during tar count for the next round
238
+ const bytes = writeArchive(projectArchivePath(name), ["-C", WORKSPACE, ...TAR_EXCLUDE_ARGS, name]);
239
+ fs.renameSync(pending, marker);
240
+ const meta = readJson(projectMetaPath(name), { name, source: defaultSource(name), created_at: started.toISOString() });
241
+ meta.snapshot = { at: new Date().toISOString(), bytes, seconds: (Date.now() - started.getTime()) / 1000 };
242
+ meta.head = gitHead(dir);
243
+ meta.branch = gitBranch(dir);
244
+ writeJson(projectMetaPath(name), meta);
245
+ log(`saved project ${name}: ${(bytes / 1e6).toFixed(1)} MB in ${meta.snapshot.seconds.toFixed(1)}s`);
246
+ return { name, bytes, at: meta.snapshot.at };
247
+ });
248
+ }
249
+
250
+ function snapshotAgent({ force = false } = {}) {
251
+ if (!exists(AGENT_DIR)) return { skipped: "missing" };
252
+ const marker = path.join(RUN_DIR, "markers", "agent");
253
+ if (!force && !changedSince(AGENT_DIR, marker, AGENT_EXCLUDES)) return { skipped: "unchanged" };
254
+ return withLock("agent", () => {
255
+ const pending = `${marker}.pending`;
256
+ touch(pending);
257
+ const bytes = writeArchive(AGENT_ARCHIVE, ["-C", AGENT_DIR, ...AGENT_EXCLUDES.map((e) => `--exclude=${e}`), "."]);
258
+ fs.renameSync(pending, marker);
259
+ return { bytes, at: new Date().toISOString() };
260
+ });
261
+ }
262
+
263
+ function snapshotAll({ force = false } = {}) {
264
+ const results = { agent: null, projects: [] };
265
+ try {
266
+ results.agent = snapshotAgent({ force });
267
+ } catch (e) {
268
+ log("agent snapshot failed:", e.message);
269
+ results.agent = { error: e.message };
270
+ }
271
+ for (const name of listProjectDirs()) {
272
+ try {
273
+ results.projects.push(snapshotProject(name, { force }));
274
+ } catch (e) {
275
+ log(`project ${name} snapshot failed:`, e.message);
276
+ results.projects.push({ name, error: e.message });
277
+ }
278
+ }
279
+ return results;
280
+ }
281
+
282
+ // ---------------------------------------------------------------- restore
283
+
284
+ function restoreAgent() {
285
+ if (!exists(AGENT_ARCHIVE)) {
286
+ log("no saved agent state yet");
287
+ return false;
288
+ }
289
+ fs.mkdirSync(AGENT_DIR, { recursive: true });
290
+ run("tar", ["--zstd", "-xf", AGENT_ARCHIVE, "-C", AGENT_DIR]);
291
+ touch(path.join(RUN_DIR, "markers", "agent"));
292
+ log("restored agent state");
293
+ return true;
294
+ }
295
+
296
+ function restoreProjects() {
297
+ if (!exists(PROJECTS_DIR)) return [];
298
+ const restored = [];
299
+ for (const name of fs.readdirSync(PROJECTS_DIR)) {
300
+ if (!NAME_RE.test(name)) continue;
301
+ const archive = projectArchivePath(name);
302
+ const dir = path.join(WORKSPACE, name);
303
+ if (!exists(archive) || exists(dir)) continue;
304
+ const started = Date.now();
305
+ run("tar", ["--zstd", "-xf", archive, "-C", WORKSPACE]);
306
+ touch(path.join(RUN_DIR, "markers", `project-${name}`));
307
+ log(`restored project ${name} in ${((Date.now() - started) / 1000).toFixed(1)}s`);
308
+ restored.push(name);
309
+ }
310
+ return restored;
311
+ }
312
+
313
+ // ---------------------------------------------------------------- pi configuration
314
+
315
+ function configurePi() {
316
+ fs.mkdirSync(AGENT_DIR, { recursive: true });
317
+
318
+ // Route the built-in Hugging Face provider through the gateway relay: the real HF
319
+ // token stays in the Space, the sandbox only holds a relay key scoped to inference.
320
+ const relayUrl = process.env.HFPI_RELAY_URL;
321
+ const modelsPath = path.join(AGENT_DIR, "models.json");
322
+ const models = readJson(modelsPath, {}) || {};
323
+ models.providers = models.providers || {};
324
+ if (relayUrl) {
325
+ models.providers.huggingface = {
326
+ ...(models.providers.huggingface || {}),
327
+ baseUrl: relayUrl,
328
+ apiKey: "$HFPI_RELAY_KEY",
329
+ };
330
+ }
331
+ writeJson(modelsPath, models);
332
+
333
+ const settingsPath = path.join(AGENT_DIR, "settings.json");
334
+ const settings = readJson(settingsPath, {}) || {};
335
+ if (!settings.defaultProvider) {
336
+ settings.defaultProvider = "huggingface";
337
+ settings.defaultModel = process.env.HFPI_DEFAULT_MODEL || "zai-org/GLM-5.3";
338
+ settings.defaultThinkingLevel = settings.defaultThinkingLevel || "medium";
339
+ }
340
+ if (!settings.enabledModels && process.env.HFPI_ENABLED_MODELS) {
341
+ settings.enabledModels = process.env.HFPI_ENABLED_MODELS.split(",").map((s) => s.trim()).filter(Boolean);
342
+ }
343
+ settings.enableInstallTelemetry = false;
344
+ writeJson(settingsPath, settings);
345
+
346
+ // Global context about this environment, rewritten each boot unless the user
347
+ // removed the managed marker to keep their own version.
348
+ const agentsPath = path.join(AGENT_DIR, "AGENTS.md");
349
+ const template = path.join(path.dirname(new URL(import.meta.url).pathname), "AGENTS.md");
350
+ const current = exists(agentsPath) ? fs.readFileSync(agentsPath, "utf8") : null;
351
+ if (exists(template) && (current === null || current.startsWith(MANAGED_MARK))) {
352
+ fs.writeFileSync(agentsPath, `${MANAGED_MARK}\n${fs.readFileSync(template, "utf8")}`);
353
+ }
354
+
355
+ const gitName = process.env.HFPI_GIT_NAME || "pi";
356
+ const gitEmail = process.env.HFPI_GIT_EMAIL || "pi@users.noreply.huggingface.co";
357
+ for (const [k, v] of [
358
+ ["safe.directory", "*"],
359
+ ["init.defaultBranch", "main"],
360
+ ["user.name", gitName],
361
+ ["user.email", gitEmail],
362
+ ]) {
363
+ run("git", ["config", "--global", k, v], { allowFail: true });
364
+ }
365
+
366
+ // Pi Web's "Use default directory" writes under ~/pi-cwd: keep it inside the
367
+ // saved workspace, and make the workspace easy to reach from the directory picker.
368
+ fs.mkdirSync(path.join(WORKSPACE, "pi-cwd"), { recursive: true });
369
+ for (const [link, target] of [
370
+ [path.join(HOME, "pi-cwd"), path.join(WORKSPACE, "pi-cwd")],
371
+ [path.join(HOME, "workspace"), WORKSPACE],
372
+ ]) {
373
+ if (!exists(link)) fs.symlinkSync(target, link);
374
+ }
375
+ }
376
+
377
+ // ---------------------------------------------------------------- pi web process
378
+
379
+ async function piWebGet(pathname, timeoutMs = 3000) {
380
+ const ctl = new AbortController();
381
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
382
+ try {
383
+ const res = await fetch(`http://127.0.0.1:${PORT}${pathname}`, { signal: ctl.signal });
384
+ if (!res.ok) return null;
385
+ return await res.json();
386
+ } catch {
387
+ return null;
388
+ } finally {
389
+ clearTimeout(t);
390
+ }
391
+ }
392
+
393
+ async function runningSessions() {
394
+ const data = await piWebGet("/api/agent/running");
395
+ if (!data) return null;
396
+ return data.runningSessionIds || [];
397
+ }
398
+
399
+ // ---------------------------------------------------------------- commands
400
+
401
+ async function cmdRun() {
402
+ const bootStarted = Date.now();
403
+ fs.mkdirSync(RUN_DIR, { recursive: true });
404
+ // Two gateways can overlap while the Space swaps containers: only one supervisor runs.
405
+ const previous = readJson(path.join(RUN_DIR, "supervisor.json"), null);
406
+ if (previous?.pid && previous.pid !== process.pid && previous.phase !== "stopped") {
407
+ try {
408
+ process.kill(previous.pid, 0);
409
+ log(`supervisor ${previous.pid} is already running`);
410
+ return;
411
+ } catch {}
412
+ }
413
+ fs.mkdirSync(WORKSPACE, { recursive: true });
414
+ writeJson(path.join(RUN_DIR, "supervisor.json"), { pid: process.pid, started_at: new Date(bootStarted).toISOString(), phase: "restoring" });
415
+ touch(path.join(RUN_DIR, "heartbeat"));
416
+
417
+ restoreAgent();
418
+ const restored = restoreProjects();
419
+ configurePi();
420
+ log(`restore done in ${((Date.now() - bootStarted) / 1000).toFixed(1)}s (${restored.length} projects)`);
421
+
422
+ const jobId = process.env.JOB_ID || "";
423
+ const allowedHosts = [process.env.PI_WEB_ALLOWED_HOSTS, jobId && `${jobId}--${PORT}.hf.jobs`].filter(Boolean).join(",");
424
+ const env = {
425
+ ...process.env,
426
+ PI_WEB_NO_OPEN: "1",
427
+ PI_WEB_SKIP_VERSION_CHECK: "1",
428
+ PI_WEB_ALLOWED_HOSTS: allowedHosts,
429
+ PI_WEB_APP_NAME: process.env.PI_WEB_APP_NAME || "Pi Web",
430
+ };
431
+ delete env.PORT;
432
+
433
+ let child = null;
434
+ let stopping = false;
435
+ let restarts = 0;
436
+ const logFile = path.join(RUN_DIR, "piweb.log");
437
+ const startPiWeb = () => {
438
+ const out = fs.openSync(logFile, "a");
439
+ child = spawn("pi-web", ["--port", String(PORT), "--hostname", "0.0.0.0", "--no-open"], {
440
+ cwd: WORKSPACE,
441
+ env,
442
+ stdio: ["ignore", out, out],
443
+ });
444
+ log(`started pi-web pid ${child.pid}`);
445
+ child.on("exit", (code, signal) => {
446
+ log(`pi-web exited code=${code} signal=${signal}`);
447
+ child = null;
448
+ if (stopping) return;
449
+ restarts++;
450
+ setTimeout(startPiWeb, Math.min(30000, 1000 * 2 ** Math.min(restarts, 5)));
451
+ });
452
+ };
453
+ startPiWeb();
454
+ writeJson(path.join(RUN_DIR, "supervisor.json"), { pid: process.pid, started_at: new Date(bootStarted).toISOString(), phase: "running", restored });
455
+
456
+ const shutdown = async (reason) => {
457
+ if (stopping) return;
458
+ stopping = true;
459
+ log(`shutting down: ${reason}`);
460
+ writeJson(path.join(RUN_DIR, "supervisor.json"), { pid: process.pid, started_at: new Date(bootStarted).toISOString(), phase: "stopping", reason });
461
+ // Stop Pi Web first so session files are complete, then save everything that changed.
462
+ if (child) {
463
+ child.kill("SIGTERM");
464
+ for (let i = 0; i < 50 && child; i++) await new Promise((r) => setTimeout(r, 100));
465
+ if (child) child.kill("SIGKILL");
466
+ }
467
+ const results = snapshotAll();
468
+ writeJson(path.join(MACHINE_DIR, "last-shutdown.json"), { reason, at: new Date().toISOString(), job_id: jobId, results });
469
+ writeJson(path.join(RUN_DIR, "supervisor.json"), { pid: process.pid, started_at: new Date(bootStarted).toISOString(), phase: "stopped", reason });
470
+ process.exit(0);
471
+ };
472
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
473
+ process.on("SIGINT", () => shutdown("SIGINT"));
474
+
475
+ let lastAgentSave = 0;
476
+ let lastProjectSave = Date.now();
477
+ for (;;) {
478
+ await new Promise((r) => setTimeout(r, 5000));
479
+ if (stopping) continue;
480
+ const now = Date.now();
481
+
482
+ if (exists(path.join(RUN_DIR, "stop-requested"))) {
483
+ fs.rmSync(path.join(RUN_DIR, "stop-requested"), { force: true });
484
+ await shutdown("requested");
485
+ continue;
486
+ }
487
+ if (now - lastAgentSave > AGENT_SAVE_SECONDS * 1000) {
488
+ lastAgentSave = now;
489
+ try {
490
+ snapshotAgent();
491
+ } catch (e) {
492
+ log("agent save failed:", e.message);
493
+ }
494
+ }
495
+ if (now - lastProjectSave > PROJECT_SAVE_SECONDS * 1000) {
496
+ lastProjectSave = now;
497
+ for (const name of listProjectDirs()) {
498
+ try {
499
+ snapshotProject(name);
500
+ } catch (e) {
501
+ log(`project ${name} save failed:`, e.message);
502
+ }
503
+ }
504
+ }
505
+ if (now - bootStarted > MAX_LIFETIME_MINUTES * 60000) {
506
+ // Jobs end at 24h; leave on our own terms with everything saved.
507
+ await shutdown("max-lifetime");
508
+ continue;
509
+ }
510
+ if (IDLE_MINUTES > 0 && now - mtimeMs(path.join(RUN_DIR, "heartbeat")) > IDLE_MINUTES * 60000) {
511
+ const running = await runningSessions();
512
+ if (running && running.length === 0) await shutdown("idle");
513
+ else if (running) touch(path.join(RUN_DIR, "heartbeat")); // an agent turn is still going: wait for it
514
+ }
515
+ }
516
+ }
517
+
518
+ function cmdImport(args) {
519
+ const name = checkName(args.name);
520
+ const dir = path.join(WORKSPACE, name);
521
+ if (exists(dir) || exists(projectMetaPath(name))) throw new Error(`project ${name} already exists`);
522
+ fs.mkdirSync(WORKSPACE, { recursive: true });
523
+ let source = args.source ? JSON.parse(args.source) : {};
524
+ const started = Date.now();
525
+ try {
526
+ if (args.git) {
527
+ const cloneArgs = ["clone", "--filter=blob:none"];
528
+ if (args.ref && args.ref !== true) cloneArgs.push("--branch", String(args.ref));
529
+ cloneArgs.push("--", String(args.git), dir);
530
+ run("git", cloneArgs, { env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_LFS_SKIP_SMUDGE: "1" } });
531
+ source = { kind: "git", url: String(args.git), ref: args.ref && args.ref !== true ? String(args.ref) : null, ...source };
532
+ } else if (args.tar) {
533
+ fs.mkdirSync(dir);
534
+ run("tar", ["-xf", String(args.tar), "-C", dir]);
535
+ fs.rmSync(String(args.tar), { force: true });
536
+ if (!exists(path.join(dir, ".git"))) {
537
+ run("git", ["-C", dir, "init", "-q"]);
538
+ run("git", ["-C", dir, "add", "-A"]);
539
+ run("git", ["-C", dir, "commit", "-q", "--allow-empty", "-m", `Import ${source.repo_id || name}`]);
540
+ }
541
+ source = { kind: "archive", ...source };
542
+ } else if (args.empty) {
543
+ fs.mkdirSync(dir);
544
+ run("git", ["-C", dir, "init", "-q"]);
545
+ run("git", ["-C", dir, "commit", "-q", "--allow-empty", "-m", "Initial commit"]);
546
+ source = { kind: "empty", ...source };
547
+ } else {
548
+ throw new Error("import needs --git URL, --tar PATH or --empty");
549
+ }
550
+ } catch (e) {
551
+ fs.rmSync(dir, { recursive: true, force: true });
552
+ throw e;
553
+ }
554
+ const meta = {
555
+ name,
556
+ source,
557
+ created_at: new Date().toISOString(),
558
+ base_commit: gitHead(dir),
559
+ import_seconds: (Date.now() - started) / 1000,
560
+ };
561
+ writeJson(projectMetaPath(name), meta);
562
+ const snap = snapshotProject(name, { force: true });
563
+ return { ...meta, path: dir, snapshot: snap };
564
+ }
565
+
566
+ function cmdDelete(args) {
567
+ const name = checkName(args.name);
568
+ return withLock(`project-${name}`, () => {
569
+ fs.rmSync(path.join(WORKSPACE, name), { recursive: true, force: true });
570
+ fs.rmSync(path.join(PROJECTS_DIR, name), { recursive: true, force: true });
571
+ fs.rmSync(path.join(RUN_DIR, "markers", `project-${name}`), { force: true });
572
+ return { name, deleted: true };
573
+ });
574
+ }
575
+
576
+ // A virtualenv (pyvenv.cfg) or cache (CACHEDIR.TAG) above `file` inside the project.
577
+ function insideTaggedDir(root, file) {
578
+ let rel = path.dirname(file);
579
+ while (rel && rel !== ".") {
580
+ const d = path.join(root, rel);
581
+ if (exists(path.join(d, "pyvenv.cfg")) || exists(path.join(d, "CACHEDIR.TAG"))) return true;
582
+ rel = path.dirname(rel);
583
+ }
584
+ return false;
585
+ }
586
+
587
+ // Stage the whole working tree (minus excluded directories) in a throwaway index, so
588
+ // a diff against the import commit sees committed and uncommitted work alike while the
589
+ // user's own staging area stays untouched.
590
+ function withWorkingIndex(name, base, fn) {
591
+ const dir = path.join(WORKSPACE, checkName(name));
592
+ const meta = readJson(projectMetaPath(name), {});
593
+ base = base || meta.base_commit;
594
+ if (!base) throw new Error(`project ${name} has no base commit to compare with`);
595
+ const index = path.join(RUN_DIR, `index-${name}-${process.pid}`);
596
+ const env = { ...process.env, GIT_INDEX_FILE: index };
597
+ fs.mkdirSync(RUN_DIR, { recursive: true });
598
+ try {
599
+ run("git", ["-C", dir, "read-tree", "HEAD"], { env });
600
+ // The same directories the archives leave out never belong in a PR or patch either.
601
+ const pathspec = [".", ...PROJECT_EXCLUDES.map((e) => `:(exclude,glob)**/${e}/**`)];
602
+ run("git", ["-C", dir, "add", "-A", "--", ...pathspec], { env });
603
+ for (const f of listStaged(dir, env)) {
604
+ if (insideTaggedDir(dir, f)) run("git", ["-C", dir, "rm", "-q", "--cached", "--", f], { env });
605
+ }
606
+ return fn({ dir, base, env });
607
+ } finally {
608
+ fs.rmSync(index, { force: true });
609
+ }
610
+ }
611
+
612
+ function listStaged(dir, env) {
613
+ const res = run("git", ["-C", dir, "diff", "--cached", "--name-only", "--diff-filter=AM", "-z", "HEAD"], { env });
614
+ return res.stdout.split("\0").filter(Boolean);
615
+ }
616
+
617
+ // Every file that differs from the import commit, as a Hub PR would see it.
618
+ function cmdChanges(args) {
619
+ const name = checkName(args.name);
620
+ return withWorkingIndex(name, args.base, ({ dir, base, env }) => {
621
+ const res = run("git", ["-C", dir, "diff", "--cached", "--name-status", "--no-renames", "-z", base], { env });
622
+ const parts = res.stdout.split("\0").filter(Boolean);
623
+ const files = [];
624
+ for (let i = 0; i + 1 < parts.length; i += 2) {
625
+ const status = parts[i];
626
+ const file = parts[i + 1];
627
+ const full = path.join(dir, file);
628
+ const st = status !== "D" && exists(full) ? fs.statSync(full) : null;
629
+ files.push({ status, path: file, size: st ? st.size : 0 });
630
+ }
631
+ return { name, base, head: gitHead(dir), files };
632
+ });
633
+ }
634
+
635
+ // The same changes as a patch that `git apply` takes on any checkout of the base.
636
+ function cmdPatch(args) {
637
+ const name = checkName(args.name);
638
+ return withWorkingIndex(name, args.base, ({ dir, base, env }) => {
639
+ const res = run("git", ["-C", dir, "diff", "--cached", "--binary", base], { env });
640
+ process.stdout.write(res.stdout);
641
+ });
642
+ }
643
+
644
+ async function cmdStatus() {
645
+ const sup = readJson(path.join(RUN_DIR, "supervisor.json"), null);
646
+ let supervisorAlive = false;
647
+ if (sup?.pid) {
648
+ try {
649
+ process.kill(sup.pid, 0);
650
+ supervisorAlive = true;
651
+ } catch {}
652
+ }
653
+ const projects = [];
654
+ for (const name of listProjectDirs()) {
655
+ const meta = readJson(projectMetaPath(name), { name, source: defaultSource(name) });
656
+ const marker = path.join(RUN_DIR, "markers", `project-${name}`);
657
+ projects.push({ ...meta, name, path: path.join(WORKSPACE, name), dirty: changedSince(path.join(WORKSPACE, name), marker, PROJECT_EXCLUDES) });
658
+ }
659
+ const running = supervisorAlive ? await runningSessions() : null;
660
+ return {
661
+ supervisor: sup ? { ...sup, alive: supervisorAlive } : null,
662
+ piweb: { healthy: running !== null, running_sessions: running || [] },
663
+ idle_seconds: Math.round((Date.now() - mtimeMs(path.join(RUN_DIR, "heartbeat"))) / 1000),
664
+ idle_minutes_limit: IDLE_MINUTES,
665
+ agent_saved_at: exists(AGENT_ARCHIVE) ? new Date(mtimeMs(AGENT_ARCHIVE)).toISOString() : null,
666
+ projects,
667
+ };
668
+ }
669
+
670
+ async function main() {
671
+ const [command, ...rest] = process.argv.slice(2);
672
+ const args = parseArgs(rest);
673
+ const print = (data) => process.stdout.write(JSON.stringify(data) + "\n");
674
+ switch (command) {
675
+ case "run":
676
+ return cmdRun();
677
+ case "snapshot": {
678
+ if (args.name) return print(snapshotProject(String(args.name), { force: Boolean(args.force) }));
679
+ if (args.agent) return print(snapshotAgent({ force: Boolean(args.force) }));
680
+ return print(snapshotAll({ force: Boolean(args.force) }));
681
+ }
682
+ case "import":
683
+ return print(cmdImport(args));
684
+ case "delete":
685
+ return print(cmdDelete(args));
686
+ case "changes":
687
+ return print(cmdChanges(args));
688
+ case "patch":
689
+ return cmdPatch(args);
690
+ case "status":
691
+ return print(await cmdStatus());
692
+ case "heartbeat":
693
+ touch(path.join(RUN_DIR, "heartbeat"));
694
+ return print({ ok: true });
695
+ case "stop":
696
+ touch(path.join(RUN_DIR, "stop-requested"));
697
+ return print({ ok: true });
698
+ default:
699
+ console.error("usage: hfpi run|snapshot|import|delete|changes|patch|status|heartbeat|stop");
700
+ process.exit(2);
701
+ }
702
+ }
703
+
704
+ main().catch((e) => {
705
+ console.error(`hfpi: ${e.message}`);
706
+ process.stdout.write(JSON.stringify({ error: e.message }) + "\n");
707
+ process.exit(1);
708
+ });
www/index.html ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Pi Web runtime</title>
7
+ <style>
8
+ :root { --bg: #fff; --text: #1a1a1a; --muted: #5e6673; color-scheme: light; }
9
+ @media (prefers-color-scheme: dark) { :root { --bg: #1a1a1a; --text: #e8e8e8; --muted: #a4a4a4; color-scheme: dark; } }
10
+ body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, sans-serif; }
11
+ main { max-width: 640px; margin: 0 auto; padding: 48px 16px; }
12
+ h1 { font-size: 22px; margin: 0 0 8px; }
13
+ p { color: var(--muted); }
14
+ code { font-size: 13px; }
15
+ </style>
16
+ </head>
17
+ <body>
18
+ <main>
19
+ <h1>🥧 Pi Web runtime</h1>
20
+ <p>
21
+ This Space only holds the Docker image that workspace machines of <b>Pi Web on Hugging Face Sandboxes</b> run.
22
+ Each machine is an HF Sandbox started in its user's own account from <code>hf.co/spaces/Mike0021/pi-web-runtime</code>.
23
+ </p>
24
+ <p>Nothing runs here. See the Files tab for what the image contains.</p>
25
+ </main>
26
+ </body>
27
+ </html>