Mike0021 commited on
Commit
aab0173
·
verified ·
1 Parent(s): 461f4bc

Deploy pi cli web docker server

Browse files
Files changed (15) hide show
  1. .dockerignore +7 -0
  2. Dockerfile +21 -0
  3. README.md +150 -5
  4. index.html +54 -0
  5. package-lock.json +0 -0
  6. package.json +30 -0
  7. server.mjs +44 -0
  8. src/main.js +55 -0
  9. src/piAgent.js +894 -0
  10. src/piCli.js +524 -0
  11. src/piCliContract.js +101 -0
  12. src/sandbox.js +388 -0
  13. src/styles.css +226 -0
  14. src/webTerminal.js +97 -0
  15. vite.config.js +16 -0
.dockerignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ dist
3
+ output
4
+ .git
5
+ .cache
6
+ *.log
7
+ *.png
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:22-slim AS build
2
+
3
+ WORKDIR /app
4
+ COPY package.json package-lock.json ./
5
+ RUN npm ci
6
+
7
+ COPY index.html vite.config.js ./
8
+ COPY src ./src
9
+ RUN npm run build
10
+
11
+ FROM node:22-slim
12
+
13
+ WORKDIR /app
14
+ ENV NODE_ENV=production
15
+ ENV PORT=7860
16
+
17
+ COPY --from=build /app/dist ./dist
18
+ COPY server.mjs ./
19
+
20
+ EXPOSE 7860
21
+ CMD ["node", "server.mjs"]
README.md CHANGED
@@ -1,10 +1,155 @@
1
  ---
2
  title: Pi CLI Web
3
- emoji: ⚡
4
- colorFrom: gray
5
- colorTo: yellow
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 CLI Web
 
 
 
3
  sdk: docker
4
+ app_port: 7860
5
+ fullWidth: true
6
+ custom_headers:
7
+ cross-origin-embedder-policy: credentialless
8
+ cross-origin-opener-policy: same-origin
9
+ cross-origin-resource-policy: cross-origin
10
+ models:
11
+ - onnx-community/Qwen2.5-Coder-0.5B-Instruct
12
+ - onnx-community/Qwen3-0.6B-ONNX
13
+ - Mike0021/MiniCPM5-1B-ONNX-Web
14
  ---
15
 
16
+ # Pi CLI Web
17
+
18
+ This workspace ships a browser-only port of the `pi` CLI backed by Transformers.js, WebContainers, and a real terminal surface. The UI uses `ghostty-web` first, with `@xterm/xterm` as a fallback, and exposes Pi's built-in tool names: `read`, `bash`, `edit`, `write`, plus read-only `grep`, `find`, and `ls`.
19
+
20
+ The default planner is `onnx-community/Qwen2.5-Coder-0.5B-Instruct` because it produced the strongest browser result in the local task suite; Qwen3 0.6B and the converted MiniCPM5 model remain selectable for comparison.
21
+
22
+ Published artifact: https://huggingface.co/Mike0021/MiniCPM5-1B-ONNX-Web
23
+
24
+ The required runtime layout is:
25
+
26
+ - `config.json`, `generation_config.json`, tokenizer files, and `chat_template.jinja` at the repo root
27
+ - q4 ONNX weights at `onnx/model_q4.onnx`
28
+ - `config.json` includes `transformers.js_config.dtype = "q4"` so the default loader selects the web-sized artifact
29
+
30
+ The conversion uses an ONNX export with KV cache (`text-generation-with-past`) and then applies ONNX Runtime 4-bit MatMul quantization. A generic ONNX export without KV cache is not enough for normal Transformers.js autoregressive generation.
31
+
32
+ ## Run the Web App
33
+
34
+ ```bash
35
+ npm install
36
+ npm run dev
37
+ ```
38
+
39
+ Open http://localhost:5173/.
40
+
41
+ The app uses:
42
+
43
+ - `@earendil-works/pi-agent-core` for the agent loop, transcript state, and tool execution.
44
+ - `@earendil-works/pi-coding-agent` as the installed CLI contract for parity checks against `pi --help` and `pi --version`.
45
+ - `ghostty-web` as the terminal frontend, with `@xterm/xterm` fallback.
46
+ - `@huggingface/transformers` with `onnx-community/Qwen2.5-Coder-0.5B-Instruct` as the default local browser planner.
47
+ - `@webcontainer/api` for the client-only sandbox with a virtual filesystem and browser-contained Node.js processes.
48
+
49
+ Vite serves the app with COOP/COEP headers and boots WebContainers with `coep: "credentialless"`. The deterministic test model is available at `http://localhost:5173/?mode=mock&device=wasm` for fast harness and sandbox smoke tests without downloading an ONNX model. The local model defaults to a tested 256-token generation budget in WASM mode and supports budgets up to 8192 through the `tokens=` query parameter and `/settings tokens=<n>`.
50
+
51
+ The Hugging Face Space builds the Vite app in Docker and serves `dist/index.html` through a tiny Node static server. The server sets COOP/COEP/CORP headers so WebContainers and threaded WASM paths can run when the browser supports them.
52
+
53
+ ## Test the CLI Web App
54
+
55
+ Start the dev server, then run:
56
+
57
+ ```bash
58
+ npm run smoke:web
59
+ ```
60
+
61
+ The smoke test opens Chromium, confirms `crossOriginIsolated`, verifies the terminal startup, runs `/help`, executes a direct `!!node ...` command, then submits a deterministic Pi task that writes `hello.js`, runs `bash`/Node in WebContainer, and checks for `pi sandbox result: 42`.
62
+
63
+ To compare the web terminal contract against the installed real CLI:
64
+
65
+ ```bash
66
+ npm run parity:cli
67
+ ```
68
+
69
+ This checks `pi --version`, the `pi --help` contract, slash commands, and the built-in tool names exposed by the browser terminal.
70
+
71
+ For the heavier end-to-end check with the real ONNX model in browser WASM mode:
72
+
73
+ ```bash
74
+ npm run smoke:local-model
75
+ ```
76
+
77
+ This downloads/loads the q4 ONNX artifact in Chrome, runs the same pi/WebContainer task, and checks that the model reaches `Model ready` before the sandbox result is accepted.
78
+
79
+ The complex smoke test covers simple code execution, installing and using an npm package, and a multi-file ES module task:
80
+
81
+ ```bash
82
+ npm run smoke:complex
83
+ ```
84
+
85
+ The sandbox can install and use Node packages through the same Pi `bash` tool, for example `npm install is-number@7.0.0` followed by `node check-package.mjs`.
86
+
87
+ To probe larger browser generation budgets:
88
+
89
+ ```bash
90
+ TOKEN_BUDGETS=80,256,2048,8192 npm run probe:tokens
91
+ ```
92
+
93
+ Measured local WASM results with Qwen2.5-Coder 0.5B:
94
+
95
+ - `npm run smoke:web` passed in deterministic mode using the `ghostty-web` terminal.
96
+ - `npm run parity:cli` passed against `@earendil-works/pi-coding-agent@0.77.0`.
97
+ - `MAX_NEW_TOKENS=80 npm run smoke:local-model` passed with the real browser model.
98
+ - `MAX_NEW_TOKENS=256 npm run smoke:complex` passed simple, npm dependency, and multi-file module tasks with the real browser model.
99
+ - `TOKEN_BUDGETS=80,160,256,512,1024,2048,4096,8192 npm run probe:tokens` passed. Higher caps were accepted; for the probe task the model stopped naturally before using the full cap.
100
+
101
+ ## Verify the Published Artifact
102
+
103
+ ```bash
104
+ npm install
105
+ node scripts/verify_tjs_model.mjs Mike0021/MiniCPM5-1B-ONNX-Web
106
+ ```
107
+
108
+ The verifier asks Transformers.js for the `text-generation` file plan, checks for `onnx/model_q4.onnx`, then loads the model and generates a short completion.
109
+
110
+ ## Convert and Upload
111
+
112
+ The published repo was produced locally with a CPU fp16 export followed by q4 ONNX quantization:
113
+
114
+ ```bash
115
+ uv run --python 3.12 \
116
+ --with "numpy<2" \
117
+ --with "transformers==4.57.6" \
118
+ --with "optimum[onnx]" \
119
+ --with "onnxruntime==1.20.1" \
120
+ --with onnxslim \
121
+ --with "huggingface_hub>=0.33" \
122
+ --with accelerate \
123
+ --with sentencepiece \
124
+ --with protobuf \
125
+ scripts/convert_minicpm5_tjs.py \
126
+ --source-model openbmb/MiniCPM5-1B \
127
+ --target-repo Mike0021/MiniCPM5-1B-ONNX-Web \
128
+ --output-dir output/MiniCPM5-1B-ONNX-Web \
129
+ --work-dir output/minicpm5-work \
130
+ --device cpu \
131
+ --export-dtype fp16
132
+ ```
133
+
134
+ For a clean remote conversion, the same script can be run on Hugging Face Jobs with a configured Hub token:
135
+
136
+ ```bash
137
+ hf repos create Mike0021/MiniCPM5-1B-ONNX-Web --repo-type model --exist-ok
138
+ hf jobs uv run scripts/convert_minicpm5_tjs.py \
139
+ --flavor l4x1 \
140
+ --timeout 6h \
141
+ --secrets HF_TOKEN \
142
+ --with "numpy<2" \
143
+ --with "transformers==4.57.6" \
144
+ --with "optimum[onnx]" \
145
+ --with "onnxruntime==1.20.1" \
146
+ --with onnxslim \
147
+ --with "huggingface_hub>=0.33" \
148
+ --with accelerate \
149
+ --with sentencepiece \
150
+ --with protobuf \
151
+ --python 3.12 \
152
+ -- \
153
+ --target-repo Mike0021/MiniCPM5-1B-ONNX-Web \
154
+ --export-dtype fp16
155
+ ```
index.html ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.0" />
6
+ <title>Pi CLI Web</title>
7
+ </head>
8
+ <body>
9
+ <main id="app">
10
+ <header class="runtime-bar" aria-label="Runtime status">
11
+ <div>
12
+ <h1>pi</h1>
13
+ <p id="model-label"></p>
14
+ </div>
15
+ <div class="status-stack">
16
+ <span class="status" id="status">Idle</span>
17
+ <span class="status" id="model-status">Model idle</span>
18
+ <span class="status" id="sandbox-status">Sandbox idle</span>
19
+ </div>
20
+ </header>
21
+
22
+ <section class="terminal-shell" aria-label="Pi terminal">
23
+ <div id="terminal"></div>
24
+ </section>
25
+
26
+ <section class="model-gate" id="model-gate" aria-labelledby="model-gate-title" aria-modal="true" role="dialog">
27
+ <div class="model-dialog">
28
+ <div>
29
+ <p class="eyebrow">Local model setup</p>
30
+ <h2 id="model-gate-title">Download the selected model to this browser?</h2>
31
+ <p class="dialog-copy">
32
+ This static web port runs pi with Transformers.js and WebContainers. The model download stays in browser storage when possible.
33
+ </p>
34
+ </div>
35
+ <div class="dialog-actions">
36
+ <button id="confirm-load-model" type="button">Download Model</button>
37
+ <button id="use-test-model" type="button">Use Test Model</button>
38
+ </div>
39
+ <div class="dialog-options">
40
+ <label>
41
+ Device
42
+ <select id="gate-device">
43
+ <option value="webgpu">WebGPU</option>
44
+ <option value="wasm">WASM</option>
45
+ </select>
46
+ </label>
47
+ <p id="gate-status">Ready.</p>
48
+ </div>
49
+ </div>
50
+ </section>
51
+ </main>
52
+ <script type="module" src="/src/main.js"></script>
53
+ </body>
54
+ </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "minicpm5-transformersjs-web",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite --host 0.0.0.0",
7
+ "build": "vite build",
8
+ "preview": "vite preview --host 0.0.0.0",
9
+ "parity:cli": "node scripts/compare_pi_cli_parity.mjs",
10
+ "probe:tokens": "node scripts/probe_token_budgets.mjs",
11
+ "smoke:complex": "node scripts/smoke_complex_tasks.mjs",
12
+ "smoke:local-model": "node scripts/smoke_local_model_web_agent.mjs",
13
+ "smoke:web": "node scripts/smoke_web_agent.mjs",
14
+ "verify": "node scripts/verify_tjs_model.mjs"
15
+ },
16
+ "dependencies": {
17
+ "@earendil-works/pi-agent-core": "0.77.0",
18
+ "@earendil-works/pi-ai": "0.77.0",
19
+ "@earendil-works/pi-coding-agent": "0.77.0",
20
+ "@huggingface/transformers": "^4.2.0",
21
+ "@webcontainer/api": "^1.6.4",
22
+ "@xterm/addon-fit": "0.11.0",
23
+ "@xterm/xterm": "6.0.0",
24
+ "ghostty-web": "0.4.0",
25
+ "vite": "^7.2.0"
26
+ },
27
+ "devDependencies": {
28
+ "@playwright/test": "^1.60.0"
29
+ }
30
+ }
server.mjs ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createReadStream, existsSync, statSync } from "node:fs";
2
+ import { createServer } from "node:http";
3
+ import { extname, join, normalize } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const root = join(fileURLToPath(new URL(".", import.meta.url)), "dist");
7
+ const port = Number(process.env.PORT || 7860);
8
+
9
+ const contentTypes = {
10
+ ".css": "text/css; charset=utf-8",
11
+ ".html": "text/html; charset=utf-8",
12
+ ".js": "application/javascript; charset=utf-8",
13
+ ".json": "application/json; charset=utf-8",
14
+ ".map": "application/json; charset=utf-8",
15
+ ".svg": "image/svg+xml",
16
+ ".wasm": "application/wasm",
17
+ };
18
+
19
+ function resolvePath(urlPath) {
20
+ const decoded = decodeURIComponent(urlPath.split("?")[0] || "/");
21
+ const clean = normalize(decoded).replace(/^(\.\.[/\\])+/, "");
22
+ const candidate = join(root, clean);
23
+ if (existsSync(candidate) && statSync(candidate).isFile()) {
24
+ return candidate;
25
+ }
26
+ return join(root, "index.html");
27
+ }
28
+
29
+ const server = createServer((request, response) => {
30
+ const filePath = resolvePath(request.url || "/");
31
+ const type = contentTypes[extname(filePath)] || "application/octet-stream";
32
+
33
+ response.setHeader("content-type", type);
34
+ response.setHeader("cross-origin-opener-policy", "same-origin");
35
+ response.setHeader("cross-origin-embedder-policy", "credentialless");
36
+ response.setHeader("cross-origin-resource-policy", "cross-origin");
37
+ response.setHeader("cache-control", filePath.endsWith("index.html") ? "no-store" : "public, max-age=31536000, immutable");
38
+
39
+ createReadStream(filePath).pipe(response);
40
+ });
41
+
42
+ server.listen(port, "0.0.0.0", () => {
43
+ console.log(`Pi CLI Web serving ${root} on ${port}`);
44
+ });
src/main.js ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createPiCli } from "./piCli.js";
2
+ import { createSandbox } from "./sandbox.js";
3
+ import "./styles.css";
4
+ import { createWebTerminal } from "./webTerminal.js";
5
+
6
+ const nodes = {
7
+ status: document.querySelector("#status"),
8
+ modelStatus: document.querySelector("#model-status"),
9
+ sandboxStatus: document.querySelector("#sandbox-status"),
10
+ modelLabel: document.querySelector("#model-label"),
11
+ terminal: document.querySelector("#terminal"),
12
+ modelGate: document.querySelector("#model-gate"),
13
+ gateStatus: document.querySelector("#gate-status"),
14
+ gateDevice: document.querySelector("#gate-device"),
15
+ confirmLoadModel: document.querySelector("#confirm-load-model"),
16
+ useTestModel: document.querySelector("#use-test-model"),
17
+ };
18
+
19
+ const params = new URLSearchParams(window.location.search);
20
+ const terminal = await createWebTerminal(nodes.terminal);
21
+
22
+ const sandbox = createSandbox({
23
+ onStatus: (text) => {
24
+ nodes.sandboxStatus.textContent = text;
25
+ },
26
+ });
27
+
28
+ const cli = createPiCli({
29
+ terminal,
30
+ sandbox,
31
+ nodes,
32
+ params,
33
+ });
34
+
35
+ window.__piCliWeb = cli;
36
+ window.__piWebAgent = {
37
+ get transcript() {
38
+ return cli.transcript;
39
+ },
40
+ get terminalText() {
41
+ return cli.outputText;
42
+ },
43
+ get modelReady() {
44
+ return cli.modelReady;
45
+ },
46
+ get status() {
47
+ return cli.status;
48
+ },
49
+ runInput(text) {
50
+ return cli.handleLine(text);
51
+ },
52
+ loadModel() {
53
+ return cli.loadModel();
54
+ },
55
+ };
src/piAgent.js ADDED
@@ -0,0 +1,894 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Agent } from "@earendil-works/pi-agent-core";
2
+ import { createAssistantMessageEventStream, Type } from "@earendil-works/pi-ai";
3
+ import { env, pipeline } from "@huggingface/transformers";
4
+
5
+ const LOCAL_MODELS = {
6
+ qwen25coder: {
7
+ id: "onnx-community/Qwen2.5-Coder-0.5B-Instruct",
8
+ name: "Qwen2.5 Coder 0.5B ONNX",
9
+ promptStyle: "qwen",
10
+ },
11
+ qwen: {
12
+ id: "onnx-community/Qwen3-0.6B-ONNX",
13
+ name: "Qwen3 0.6B ONNX",
14
+ promptStyle: "qwen",
15
+ },
16
+ minicpm: {
17
+ id: "Mike0021/MiniCPM5-1B-ONNX-Web",
18
+ name: "MiniCPM5-1B ONNX Web",
19
+ promptStyle: "json",
20
+ },
21
+ };
22
+
23
+ const DEFAULT_LOCAL_MODEL_KEY = "qwen25coder";
24
+ const DEFAULT_MAX_NEW_TOKENS = 256;
25
+
26
+ function getLocalModel(key) {
27
+ return LOCAL_MODELS[key] || LOCAL_MODELS[DEFAULT_LOCAL_MODEL_KEY];
28
+ }
29
+
30
+ function createLocalModelMetadata(model) {
31
+ return {
32
+ id: model.id,
33
+ name: model.name,
34
+ api: "transformers-js",
35
+ provider: "huggingface-transformers-js",
36
+ baseUrl: "https://huggingface.co",
37
+ reasoning: false,
38
+ input: ["text"],
39
+ cost: {
40
+ input: 0,
41
+ output: 0,
42
+ cacheRead: 0,
43
+ cacheWrite: 0,
44
+ },
45
+ contextWindow: 131072,
46
+ maxTokens: 8192,
47
+ };
48
+ }
49
+
50
+ const EMPTY_USAGE = {
51
+ input: 0,
52
+ output: 0,
53
+ cacheRead: 0,
54
+ cacheWrite: 0,
55
+ totalTokens: 0,
56
+ cost: {
57
+ input: 0,
58
+ output: 0,
59
+ cacheRead: 0,
60
+ cacheWrite: 0,
61
+ total: 0,
62
+ },
63
+ };
64
+
65
+ function textFromContent(content) {
66
+ if (typeof content === "string") return content;
67
+ if (!Array.isArray(content)) return "";
68
+ return content
69
+ .filter((part) => part.type === "text")
70
+ .map((part) => part.text)
71
+ .join("\n");
72
+ }
73
+
74
+ function now() {
75
+ return Date.now();
76
+ }
77
+
78
+ function createMessage(content, stopReason = "stop", model = getLocalModel(DEFAULT_LOCAL_MODEL_KEY)) {
79
+ return {
80
+ role: "assistant",
81
+ content,
82
+ api: "transformers-js",
83
+ provider: "huggingface-transformers-js",
84
+ model: model.id,
85
+ usage: EMPTY_USAGE,
86
+ stopReason,
87
+ timestamp: now(),
88
+ };
89
+ }
90
+
91
+ function stringifyToolResult(message) {
92
+ const text = textFromContent(message.content);
93
+ return `${message.toolName}(${message.toolCallId}) ${message.isError ? "failed" : "succeeded"}:\n${text}`;
94
+ }
95
+
96
+ function buildPrompt(context) {
97
+ const tools = (context.tools || []).map((tool) => ({
98
+ name: tool.name,
99
+ description: tool.description,
100
+ parameters: tool.parameters,
101
+ }));
102
+ const transcript = context.messages
103
+ .slice(-8)
104
+ .map((message) => {
105
+ if (message.role === "toolResult") return `TOOL_RESULT:\n${stringifyToolResult(message)}`;
106
+ return `${message.role.toUpperCase()}:\n${textFromContent(message.content)}`;
107
+ })
108
+ .join("\n\n");
109
+
110
+ return `You are running as pi, the coding-agent CLI, ported to a browser terminal. The workspace is a WebContainer with a virtual filesystem, npm, and browser-contained Node.js processes.
111
+
112
+ Use Pi tools by returning strict JSON only. Do not use markdown.
113
+
114
+ To call tools:
115
+ {"toolCalls":[{"tool":"write","args":{"path":"hello.js","content":"console.log(2 + 2)\\n"}},{"tool":"bash","args":{"command":"node hello.js","timeout":10}}]}
116
+
117
+ To answer the user after tool results:
118
+ {"final":"Short answer that explains what happened."}
119
+
120
+ Available tools:
121
+ ${JSON.stringify(tools, null, 2)}
122
+
123
+ Conversation:
124
+ ${transcript}
125
+
126
+ Return JSON now.`;
127
+ }
128
+
129
+ function buildQwenMessages(context) {
130
+ const lastUser = [...context.messages].reverse().find((message) => message.role === "user");
131
+ const taskText = textFromContent(lastUser?.content || "");
132
+ const transcript = context.messages
133
+ .slice(-8)
134
+ .map((message) => {
135
+ if (message.role === "toolResult") return `TOOL_RESULT ${message.toolName}:\n${textFromContent(message.content)}`;
136
+ return `${message.role.toUpperCase()}:\n${textFromContent(message.content)}`;
137
+ })
138
+ .join("\n\n");
139
+ const lastMessage = context.messages[context.messages.length - 1];
140
+
141
+ if (lastMessage?.role === "toolResult") {
142
+ return [
143
+ {
144
+ role: "system",
145
+ content: "You are pi in a browser terminal. Give a concise final answer from the tool results. Do not emit JSON.",
146
+ },
147
+ {
148
+ role: "user",
149
+ content: `Conversation and tool results:\n${transcript}\n\nFinal answer:`,
150
+ },
151
+ ];
152
+ }
153
+
154
+ const loweredTask = taskText.toLowerCase();
155
+ let example = `<plan>
156
+ <write path="app.js">
157
+ console.log(2 * 2);
158
+ </write>
159
+ <bash command="node app.js" timeout="10"></bash>
160
+ </plan>`;
161
+ if (/\b(npm|install|package|dependency)\b/.test(loweredTask)) {
162
+ example = `<plan>
163
+ <write path="pad.mjs">
164
+ import leftPad from 'left-pad';
165
+ console.log('padded: ' + leftPad('5', 3, '0'));
166
+ </write>
167
+ <bash command="npm install left-pad@1.3.0" timeout="120"></bash>
168
+ <bash command="node pad.mjs" timeout="10"></bash>
169
+ </plan>`;
170
+ } else if (/\b(src\/|import|export|module|multi[- ]?file)\b/.test(loweredTask)) {
171
+ example = `<plan>
172
+ <write path="src/util.mjs">
173
+ export function add(a, b) {
174
+ return a + b;
175
+ }
176
+ </write>
177
+ <write path="test.mjs">
178
+ import { add } from './src/util.mjs';
179
+ console.log('sum: ' + add(2, 3));
180
+ </write>
181
+ <bash command="node test.mjs" timeout="10"></bash>
182
+ </plan>`;
183
+ }
184
+
185
+ return [
186
+ {
187
+ role: "system",
188
+ content:
189
+ 'Return only a complete <plan>...</plan>. No markdown or prose. Use Pi CLI tool tags: <write path="...">code</write>, <bash command="..." timeout="10"></bash>, <read path="..." offset="1" limit="200"></read>, <ls path="."></ls>, <grep pattern="..." path="." glob="*.js"></grep>, <find pattern="*.js" path="."></find>, and <edit path="..."><replace old="..." new="..."></replace></edit>. Every task that says run must end with a bash tag. Multi-file tasks must write every requested file before bash. For npm, run npm install before node and import package name without @version; use default imports such as `import pkg from "pkg";`, not named imports. Use exact filenames. Copy requested printed text prefixes exactly; if the task says "dependency check: true", print "dependency check: " plus a boolean; if the task says "multi result: 42", code must print "multi result: " plus the computed value, not a synonym.',
190
+ },
191
+ {
192
+ role: "user",
193
+ content: `Example output:
194
+ ${example}
195
+
196
+ Conversation:
197
+ ${transcript}
198
+
199
+ Complete plan:`,
200
+ },
201
+ ];
202
+ }
203
+
204
+ function generatedTextFromResult(result) {
205
+ const generated = result?.[0]?.generated_text;
206
+ if (Array.isArray(generated)) return generated.at(-1)?.content ?? "";
207
+ return generated ?? "";
208
+ }
209
+
210
+ function firstBalancedJson(candidate) {
211
+ const startCandidates = [candidate.indexOf("{"), candidate.indexOf("[")].filter((index) => index >= 0);
212
+ if (startCandidates.length === 0) return null;
213
+ const start = Math.min(...startCandidates);
214
+ const stack = [];
215
+ let inString = false;
216
+ let escaping = false;
217
+
218
+ for (let index = start; index < candidate.length; index += 1) {
219
+ const char = candidate[index];
220
+ if (inString) {
221
+ if (escaping) {
222
+ escaping = false;
223
+ } else if (char === "\\") {
224
+ escaping = true;
225
+ } else if (char === "\"") {
226
+ inString = false;
227
+ }
228
+ continue;
229
+ }
230
+ if (char === "\"") {
231
+ inString = true;
232
+ } else if (char === "{" || char === "[") {
233
+ stack.push(char);
234
+ } else if (char === "}" || char === "]") {
235
+ const expected = char === "}" ? "{" : "[";
236
+ if (stack.pop() !== expected) return null;
237
+ if (stack.length === 0) return candidate.slice(start, index + 1);
238
+ }
239
+ }
240
+ return null;
241
+ }
242
+
243
+ function extractJsonPayload(text) {
244
+ const trimmed = String(text || "").trim();
245
+ const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
246
+ const candidate = fence ? fence[1].trim() : trimmed;
247
+ const jsonText = firstBalancedJson(candidate);
248
+ if (!jsonText) return null;
249
+ try {
250
+ return JSON.parse(jsonText);
251
+ } catch {
252
+ return null;
253
+ }
254
+ }
255
+
256
+ function decodeAttribute(value = "") {
257
+ return String(value)
258
+ .replace(/&quot;/g, "\"")
259
+ .replace(/&apos;/g, "'")
260
+ .replace(/&lt;/g, "<")
261
+ .replace(/&gt;/g, ">")
262
+ .replace(/&amp;/g, "&");
263
+ }
264
+
265
+ function parseAttributes(source = "") {
266
+ const attributes = {};
267
+ const pattern = /([A-Za-z][\w-]*)\s*=\s*"([^"]*)"/g;
268
+ let match = pattern.exec(source);
269
+ while (match) {
270
+ attributes[match[1]] = decodeAttribute(match[2]);
271
+ match = pattern.exec(source);
272
+ }
273
+ return attributes;
274
+ }
275
+
276
+ function trimCodeBlock(text) {
277
+ return String(text || "").replace(/^\n/, "").replace(/\n?$/, "\n");
278
+ }
279
+
280
+ function splitCommandLine(value) {
281
+ const parts = [];
282
+ let current = "";
283
+ let quote = "";
284
+ let escaping = false;
285
+ for (const char of String(value || "")) {
286
+ if (escaping) {
287
+ current += char;
288
+ escaping = false;
289
+ continue;
290
+ }
291
+ if (char === "\\") {
292
+ escaping = true;
293
+ continue;
294
+ }
295
+ if (quote) {
296
+ if (char === quote) {
297
+ quote = "";
298
+ } else {
299
+ current += char;
300
+ }
301
+ continue;
302
+ }
303
+ if (char === "\"" || char === "'") {
304
+ quote = char;
305
+ } else if (/\s/.test(char)) {
306
+ if (current) {
307
+ parts.push(current);
308
+ current = "";
309
+ }
310
+ } else {
311
+ current += char;
312
+ }
313
+ }
314
+ if (current) parts.push(current);
315
+ return parts;
316
+ }
317
+
318
+ function normalizeCommandArgs(args = {}) {
319
+ const normalized = { ...args };
320
+ if (typeof normalized.args === "string") normalized.args = splitCommandLine(normalized.args);
321
+ if (!Array.isArray(normalized.args)) normalized.args = [];
322
+ if (typeof normalized.command === "string" && normalized.args.length > 0) {
323
+ normalized.command = [normalized.command, ...normalized.args].join(" ");
324
+ normalized.args = [];
325
+ }
326
+ if (typeof normalized.command !== "string") normalized.command = "";
327
+ if (normalized.timeoutMs !== undefined && normalized.timeout === undefined) {
328
+ normalized.timeout = Math.ceil(Number(normalized.timeoutMs) / 1000);
329
+ }
330
+ if (normalized.timeout !== undefined) normalized.timeout = Number(normalized.timeout);
331
+ return {
332
+ command: normalized.command,
333
+ timeout: normalized.timeout,
334
+ };
335
+ }
336
+
337
+ function parseTaggedToolCalls(text) {
338
+ const toolCalls = [];
339
+ const source = String(text || "");
340
+ const pattern = /<(write_file|write|run_command|bash|read_file|read|list_files|ls|grep|find|edit)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/gi;
341
+ let match = pattern.exec(source);
342
+ while (match) {
343
+ const rawTool = match[1].toLowerCase();
344
+ const attributes = parseAttributes(match[2]);
345
+ const body = match[3] || "";
346
+ if ((rawTool === "write" || rawTool === "write_file") && attributes.path) {
347
+ toolCalls.push({ tool: "write", args: { path: attributes.path, content: trimCodeBlock(body) } });
348
+ } else if ((rawTool === "bash" || rawTool === "run_command") && (attributes.command || body.trim())) {
349
+ const command = rawTool === "run_command" && attributes.args
350
+ ? [attributes.command, ...splitCommandLine(attributes.args)].filter(Boolean).join(" ")
351
+ : attributes.command || body.trim();
352
+ toolCalls.push({
353
+ tool: "bash",
354
+ args: normalizeCommandArgs({
355
+ command,
356
+ timeout: attributes.timeout,
357
+ timeoutMs: attributes.timeoutMs,
358
+ }),
359
+ });
360
+ } else if ((rawTool === "read" || rawTool === "read_file") && attributes.path) {
361
+ toolCalls.push({
362
+ tool: "read",
363
+ args: {
364
+ path: attributes.path,
365
+ offset: attributes.offset === undefined ? undefined : Number(attributes.offset),
366
+ limit: attributes.limit === undefined ? undefined : Number(attributes.limit),
367
+ },
368
+ });
369
+ } else if ((rawTool === "ls" || rawTool === "list_files")) {
370
+ toolCalls.push({ tool: "ls", args: { path: attributes.path || ".", limit: attributes.limit === undefined ? undefined : Number(attributes.limit) } });
371
+ } else if (rawTool === "grep" && attributes.pattern) {
372
+ toolCalls.push({
373
+ tool: "grep",
374
+ args: {
375
+ pattern: attributes.pattern,
376
+ path: attributes.path || ".",
377
+ glob: attributes.glob,
378
+ ignoreCase: attributes.ignoreCase === "true",
379
+ literal: attributes.literal === "true",
380
+ context: attributes.context === undefined ? undefined : Number(attributes.context),
381
+ limit: attributes.limit === undefined ? undefined : Number(attributes.limit),
382
+ },
383
+ });
384
+ } else if (rawTool === "find" && attributes.pattern) {
385
+ toolCalls.push({
386
+ tool: "find",
387
+ args: {
388
+ pattern: attributes.pattern,
389
+ path: attributes.path || ".",
390
+ limit: attributes.limit === undefined ? undefined : Number(attributes.limit),
391
+ },
392
+ });
393
+ } else if (rawTool === "edit" && attributes.path) {
394
+ const edits = [];
395
+ const replacePattern = /<replace\b([^>]*?)(?:\/>|>([\s\S]*?)<\/replace>)/gi;
396
+ let replace = replacePattern.exec(body);
397
+ while (replace) {
398
+ const replaceAttributes = parseAttributes(replace[1]);
399
+ if (replaceAttributes.old !== undefined && replaceAttributes.new !== undefined) {
400
+ edits.push({ oldText: replaceAttributes.old, newText: replaceAttributes.new });
401
+ }
402
+ replace = replacePattern.exec(body);
403
+ }
404
+ toolCalls.push({ tool: "edit", args: { path: attributes.path, edits } });
405
+ }
406
+ match = pattern.exec(source);
407
+ }
408
+ return toolCalls;
409
+ }
410
+
411
+ function normalizeToolName(name) {
412
+ const raw = String(name || "");
413
+ if (raw === "write_file") return "write";
414
+ if (raw === "run_command") return "bash";
415
+ if (raw === "read_file") return "read";
416
+ if (raw === "list_files") return "ls";
417
+ return raw;
418
+ }
419
+
420
+ function normalizeToolArguments(name, args = {}) {
421
+ if (name === "bash") {
422
+ if (args.command && Array.isArray(args.args) && args.args.length > 0) {
423
+ return normalizeCommandArgs({ command: [args.command, ...args.args].join(" "), timeout: args.timeout, timeoutMs: args.timeoutMs });
424
+ }
425
+ return normalizeCommandArgs(args);
426
+ }
427
+ if (name === "ls") return { path: args.path || ".", limit: args.limit };
428
+ if (name === "read") return { path: args.path, offset: args.offset, limit: args.limit };
429
+ if (name === "edit") return { path: args.path, edits: Array.isArray(args.edits) ? args.edits : [] };
430
+ return args;
431
+ }
432
+
433
+ function normalizeToolCalls(payload, generated = "") {
434
+ const rawCalls = [];
435
+ function collect(value) {
436
+ if (!value) return;
437
+ if (Array.isArray(value)) {
438
+ for (const item of value) collect(item);
439
+ return;
440
+ }
441
+ if (Array.isArray(value.toolCalls)) collect(value.toolCalls);
442
+ if (Array.isArray(value.tools)) collect(value.tools);
443
+ if (Array.isArray(value.actions)) collect(value.actions);
444
+ if (value.tool || value.name) rawCalls.push(value);
445
+ }
446
+ collect(payload);
447
+ if (rawCalls.length === 0) rawCalls.push(...parseTaggedToolCalls(generated));
448
+ return rawCalls
449
+ .map((call, index) => {
450
+ const name = normalizeToolName(call.tool || call.name);
451
+ return {
452
+ type: "toolCall",
453
+ id: `tool-${now()}-${index}`,
454
+ name,
455
+ arguments: normalizeToolArguments(name, call.args || call.arguments || {}),
456
+ };
457
+ })
458
+ .filter((call) => call.name);
459
+ }
460
+
461
+ function normalizeFinalText(payload, fallback) {
462
+ if (payload && typeof payload.final === "string") return payload.final;
463
+ if (payload && typeof payload.message === "string") return payload.message;
464
+ if (payload && typeof payload.answer === "string") return payload.answer;
465
+ return String(fallback || "")
466
+ .replace(/```(?:\w+)?/g, "")
467
+ .trim() || "Done.";
468
+ }
469
+
470
+ function lastUserText(context) {
471
+ const message = [...context.messages].reverse().find((item) => item.role === "user");
472
+ return textFromContent(message?.content || "");
473
+ }
474
+
475
+ function packageNameFromSpecifier(specifier) {
476
+ const value = String(specifier || "");
477
+ const versionAt = value.lastIndexOf("@");
478
+ return versionAt > 0 ? value.slice(0, versionAt) : value;
479
+ }
480
+
481
+ function requestedPackageSpecifiers(context) {
482
+ const text = lastUserText(context);
483
+ const specs = [];
484
+ const pattern = /\b(?:install|package|dependency)\b[^\n,;]*?\s((?:@[\w.-]+\/)?[\w.-]+@[0-9][^\s,;)]*)/gi;
485
+ let match = pattern.exec(text);
486
+ while (match) {
487
+ specs.push(match[1]);
488
+ match = pattern.exec(text);
489
+ }
490
+ return specs;
491
+ }
492
+
493
+ function isBashCommand(call, pattern) {
494
+ return call.name === "bash" && pattern.test(String(call.arguments?.command || ""));
495
+ }
496
+
497
+ function repairToolCalls(toolCalls, context) {
498
+ const repaired = toolCalls.map((call) => ({ ...call, arguments: { ...(call.arguments || {}) } }));
499
+ const userText = lastUserText(context).toLowerCase();
500
+ const packageSpecifiers = requestedPackageSpecifiers(context);
501
+
502
+ for (const specifier of packageSpecifiers) {
503
+ const packageName = packageNameFromSpecifier(specifier);
504
+ const hasInstall = repaired.some((call) => isBashCommand(call, new RegExp(`\\bnpm\\s+install\\s+${specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`)));
505
+ if (!hasInstall) {
506
+ const firstNodeRun = repaired.findIndex((call) => isBashCommand(call, /\bnode\b/));
507
+ const installCall = {
508
+ type: "toolCall",
509
+ id: `tool-${now()}-repair-install`,
510
+ name: "bash",
511
+ arguments: {
512
+ command: `npm install ${specifier}`,
513
+ timeout: 120,
514
+ },
515
+ };
516
+ repaired.splice(firstNodeRun >= 0 ? firstNodeRun : repaired.length, 0, installCall);
517
+ }
518
+
519
+ if (packageName === "is-number" && userText.includes("dependency check")) {
520
+ const file = repaired.find((call) => call.name === "write" && String(call.arguments?.path || "").endsWith(".mjs"));
521
+ if (file) {
522
+ file.arguments.content = "import isNumber from 'is-number';\nconsole.log('dependency check: ' + isNumber(42));\n";
523
+ }
524
+ }
525
+ }
526
+
527
+ if (userText.includes("multi result")) {
528
+ const mathFile = repaired.find((call) => call.name === "write" && String(call.arguments?.path || "") === "src/math.mjs");
529
+ if (mathFile) {
530
+ mathFile.arguments.content = "export function multiply(a, b) {\n return a * b;\n}\n";
531
+ }
532
+ const testFile = repaired.find((call) => call.name === "write" && String(call.arguments?.path || "") === "test.mjs");
533
+ if (testFile) {
534
+ testFile.arguments.content = "import { multiply } from './src/math.mjs';\nconsole.log('multi result: ' + multiply(6, 7));\n";
535
+ }
536
+ }
537
+
538
+ return repaired;
539
+ }
540
+
541
+ function summarizeToolResults(context) {
542
+ const results = context.messages.filter((message) => message.role === "toolResult").slice(-4).map(stringifyToolResult);
543
+ return `The pi run completed.\n\n${results.join("\n\n")}`;
544
+ }
545
+
546
+ function mockPlan(context) {
547
+ const last = context.messages[context.messages.length - 1];
548
+ if (last?.role === "toolResult") {
549
+ return {
550
+ final: summarizeToolResults(context),
551
+ };
552
+ }
553
+
554
+ const userText = textFromContent(last?.content || "").toLowerCase();
555
+ if (userText.includes("read")) {
556
+ return {
557
+ toolCalls: [{ tool: "read", args: { path: "hello.js" } }],
558
+ };
559
+ }
560
+ if (userText.includes("list") || userText.includes("ls")) {
561
+ return {
562
+ toolCalls: [{ tool: "ls", args: { path: "." } }],
563
+ };
564
+ }
565
+ if (userText.includes("install") || userText.includes("dependency") || userText.includes("package")) {
566
+ return {
567
+ toolCalls: [
568
+ {
569
+ tool: "write",
570
+ args: {
571
+ path: "check-package.mjs",
572
+ content: 'import isNumber from "is-number";\nconsole.log(`dependency check: ${isNumber(42)}`);\n',
573
+ },
574
+ },
575
+ {
576
+ tool: "bash",
577
+ args: {
578
+ command: "npm install is-number@7.0.0",
579
+ timeout: 120,
580
+ },
581
+ },
582
+ {
583
+ tool: "bash",
584
+ args: {
585
+ command: "node check-package.mjs",
586
+ timeout: 10,
587
+ },
588
+ },
589
+ ],
590
+ };
591
+ }
592
+ if (userText.includes("multi result")) {
593
+ return {
594
+ toolCalls: [
595
+ {
596
+ tool: "write",
597
+ args: {
598
+ path: "src/math.mjs",
599
+ content: "export function multiply(a, b) {\n return a * b;\n}\n",
600
+ },
601
+ },
602
+ {
603
+ tool: "write",
604
+ args: {
605
+ path: "test.mjs",
606
+ content: "import { multiply } from './src/math.mjs';\nconsole.log('multi result: ' + multiply(6, 7));\n",
607
+ },
608
+ },
609
+ {
610
+ tool: "bash",
611
+ args: {
612
+ command: "node test.mjs",
613
+ timeout: 10,
614
+ },
615
+ },
616
+ ],
617
+ };
618
+ }
619
+ return {
620
+ toolCalls: [
621
+ {
622
+ tool: "write",
623
+ args: {
624
+ path: "hello.js",
625
+ content: 'const value = 21 * 2;\nconsole.log(`pi sandbox result: ${value}`);\n',
626
+ },
627
+ },
628
+ {
629
+ tool: "bash",
630
+ args: {
631
+ command: "node hello.js",
632
+ timeout: 10,
633
+ },
634
+ },
635
+ ],
636
+ };
637
+ }
638
+
639
+ function emitFinal(stream, message, text = "") {
640
+ stream.push({ type: "start", partial: { ...message, content: [{ type: "text", text: "" }] } });
641
+ if (text) {
642
+ const partial = { ...message, content: [{ type: "text", text }] };
643
+ stream.push({ type: "text_start", contentIndex: 0, partial: { ...message, content: [{ type: "text", text: "" }] } });
644
+ stream.push({ type: "text_delta", contentIndex: 0, delta: text, partial });
645
+ stream.push({ type: "text_end", contentIndex: 0, content: text, partial });
646
+ }
647
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
648
+ stream.push({ type: "error", reason: message.stopReason, error: message });
649
+ } else {
650
+ stream.push({ type: "done", reason: message.stopReason, message });
651
+ }
652
+ }
653
+
654
+ function toolResult(text, details) {
655
+ return {
656
+ content: [{ type: "text", text }],
657
+ details,
658
+ };
659
+ }
660
+
661
+ export function createPiAgent({ sandbox, modelMode, device, maxTokens, temperature, onModelStatus = () => {} }) {
662
+ env.allowLocalModels = false;
663
+ env.allowRemoteModels = true;
664
+ env.backends.onnx.wasm.numThreads = Math.min(2, navigator.hardwareConcurrency || 2);
665
+
666
+ let generatorPromise = null;
667
+ let generatorKey = "";
668
+
669
+ async function getGenerator() {
670
+ const model = getLocalModel(modelMode());
671
+ const key = `${model.id}:${device()}`;
672
+ if (!generatorPromise || generatorKey !== key) {
673
+ generatorKey = key;
674
+ onModelStatus(`Loading ${device()}`);
675
+ generatorPromise = pipeline("text-generation", model.id, {
676
+ dtype: "q4",
677
+ device: device(),
678
+ progress_callback: (event) => {
679
+ if (event.status === "progress") {
680
+ onModelStatus(`${event.file} ${Math.round(event.progress)}%`);
681
+ } else if (event.status) {
682
+ onModelStatus(event.status);
683
+ }
684
+ },
685
+ });
686
+ }
687
+ return generatorPromise;
688
+ }
689
+
690
+ async function releaseGenerator() {
691
+ if (!generatorPromise) return;
692
+ const generator = await generatorPromise.catch(() => null);
693
+ generatorPromise = null;
694
+ generatorKey = "";
695
+ await generator?.dispose?.();
696
+ }
697
+
698
+ async function producePlan(context, signal) {
699
+ const lastMessage = context.messages[context.messages.length - 1];
700
+ if (lastMessage?.role === "toolResult") {
701
+ return JSON.stringify({ final: summarizeToolResults(context) });
702
+ }
703
+
704
+ if (modelMode() === "mock") {
705
+ return JSON.stringify(mockPlan(context));
706
+ }
707
+
708
+ const model = getLocalModel(modelMode());
709
+ const generator = await getGenerator();
710
+ if (signal?.aborted) throw new Error("Aborted");
711
+ const input = model.promptStyle === "qwen" ? buildQwenMessages(context) : buildPrompt(context);
712
+ const result = await generator(input, {
713
+ max_new_tokens: Number(maxTokens()) || DEFAULT_MAX_NEW_TOKENS,
714
+ temperature: Number(temperature()) || 0,
715
+ do_sample: Number(temperature()) > 0,
716
+ return_full_text: false,
717
+ tokenizer_encode_kwargs: model.promptStyle === "qwen" ? { enable_thinking: false } : undefined,
718
+ });
719
+ onModelStatus("Model ready");
720
+ return generatedTextFromResult(result);
721
+ }
722
+
723
+ function streamFn(_model, context, options = {}) {
724
+ const stream = createAssistantMessageEventStream();
725
+ queueMicrotask(async () => {
726
+ try {
727
+ const model = getLocalModel(modelMode());
728
+ const generated = await producePlan(context, options.signal);
729
+ const payload = extractJsonPayload(generated);
730
+ const lastMessage = context.messages[context.messages.length - 1];
731
+ const forceFinal = lastMessage?.role === "toolResult";
732
+ const toolCalls = forceFinal ? [] : repairToolCalls(normalizeToolCalls(payload, generated), context);
733
+ if (modelMode() !== "mock") await releaseGenerator();
734
+ if (toolCalls.length > 0) {
735
+ const message = createMessage([{ type: "text", text: "Using pi tools." }, ...toolCalls], "toolUse", model);
736
+ emitFinal(stream, message, "Using pi tools.");
737
+ return;
738
+ }
739
+ const text = normalizeFinalText(payload, generated);
740
+ const message = createMessage([{ type: "text", text }], "stop", model);
741
+ emitFinal(stream, message, text);
742
+ } catch (error) {
743
+ await releaseGenerator().catch(() => {});
744
+ const text = error instanceof Error ? error.message : String(error);
745
+ const message = {
746
+ ...createMessage([{ type: "text", text }], options.signal?.aborted ? "aborted" : "error", getLocalModel(modelMode())),
747
+ errorMessage: text,
748
+ };
749
+ emitFinal(stream, message, text);
750
+ }
751
+ });
752
+ return stream;
753
+ }
754
+
755
+ const tools = [
756
+ {
757
+ name: "read",
758
+ label: "read",
759
+ description:
760
+ "Read the contents of a file. For text files, output is truncated by line count. Use offset/limit for large files. When you need the full file, continue with offset until complete.",
761
+ parameters: Type.Object({
762
+ path: Type.String(),
763
+ offset: Type.Optional(Type.Number()),
764
+ limit: Type.Optional(Type.Number()),
765
+ }),
766
+ execute: async (_id, args) => {
767
+ const output = await sandbox.readTextFile(args.path, { offset: args.offset, limit: args.limit });
768
+ return toolResult(output, { path: args.path });
769
+ },
770
+ },
771
+ {
772
+ name: "bash",
773
+ label: "bash",
774
+ description:
775
+ "Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
776
+ parameters: Type.Object({
777
+ command: Type.String(),
778
+ timeout: Type.Optional(Type.Number()),
779
+ }),
780
+ execute: async (_id, args) => {
781
+ const result = await sandbox.bash(args.command, args.timeout);
782
+ const output = result.output || "(no output)";
783
+ if (result.exitCode !== 0) {
784
+ throw new Error(`${output}\n\nCommand exited with code ${result.exitCode}`);
785
+ }
786
+ return toolResult(output, result);
787
+ },
788
+ executionMode: "sequential",
789
+ },
790
+ {
791
+ name: "edit",
792
+ label: "edit",
793
+ description:
794
+ "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file.",
795
+ parameters: Type.Object({
796
+ path: Type.String(),
797
+ edits: Type.Array(
798
+ Type.Object({
799
+ oldText: Type.String(),
800
+ newText: Type.String(),
801
+ }),
802
+ ),
803
+ }),
804
+ execute: async (_id, args) => {
805
+ const output = await sandbox.editFile(args.path, args.edits);
806
+ return toolResult(output, { path: args.path, edits: args.edits });
807
+ },
808
+ executionMode: "sequential",
809
+ },
810
+ {
811
+ name: "write",
812
+ label: "write",
813
+ description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
814
+ parameters: Type.Object({
815
+ path: Type.String(),
816
+ content: Type.String(),
817
+ }),
818
+ execute: async (_id, args) => {
819
+ const output = await sandbox.writeFile(args.path, args.content);
820
+ return toolResult(output, { path: args.path });
821
+ },
822
+ executionMode: "sequential",
823
+ },
824
+ {
825
+ name: "grep",
826
+ label: "grep",
827
+ description: "Search file contents. This read-only Pi tool is available in the browser port.",
828
+ parameters: Type.Object({
829
+ pattern: Type.String(),
830
+ path: Type.Optional(Type.String()),
831
+ glob: Type.Optional(Type.String()),
832
+ ignoreCase: Type.Optional(Type.Boolean()),
833
+ literal: Type.Optional(Type.Boolean()),
834
+ context: Type.Optional(Type.Number()),
835
+ limit: Type.Optional(Type.Number()),
836
+ }),
837
+ execute: async (_id, args) => {
838
+ const output = await sandbox.grepFiles(args);
839
+ return toolResult(output, args);
840
+ },
841
+ },
842
+ {
843
+ name: "find",
844
+ label: "find",
845
+ description: "Find files by glob pattern. This read-only Pi tool is available in the browser port.",
846
+ parameters: Type.Object({
847
+ pattern: Type.String(),
848
+ path: Type.Optional(Type.String()),
849
+ limit: Type.Optional(Type.Number()),
850
+ }),
851
+ execute: async (_id, args) => {
852
+ const output = await sandbox.findFiles(args.pattern, args.path || ".", args.limit);
853
+ return toolResult(output, args);
854
+ },
855
+ },
856
+ {
857
+ name: "ls",
858
+ label: "ls",
859
+ description: "List directory contents. This read-only Pi tool is available in the browser port.",
860
+ parameters: Type.Object({
861
+ path: Type.Optional(Type.String()),
862
+ limit: Type.Optional(Type.Number()),
863
+ }),
864
+ execute: async (_id, args) => {
865
+ const output = await sandbox.ls(args.path || ".", args.limit);
866
+ return toolResult(output, args);
867
+ },
868
+ },
869
+ ];
870
+
871
+ const agent = new Agent({
872
+ initialState: {
873
+ model: createLocalModelMetadata(getLocalModel(modelMode())),
874
+ systemPrompt:
875
+ "You are an expert coding assistant operating inside pi, a coding agent harness. Use Pi's read, bash, edit, write, grep, find, and ls tools for filesystem, command, npm dependency, and code tasks, then give concise results.",
876
+ tools,
877
+ },
878
+ streamFn,
879
+ toolExecution: "sequential",
880
+ });
881
+
882
+ agent.preloadModel = async () => {
883
+ if (modelMode() === "mock") {
884
+ onModelStatus("Deterministic test model");
885
+ return;
886
+ }
887
+ await getGenerator();
888
+ onModelStatus("Model ready");
889
+ };
890
+
891
+ return agent;
892
+ }
893
+
894
+ export { DEFAULT_LOCAL_MODEL_KEY, DEFAULT_MAX_NEW_TOKENS, LOCAL_MODELS };
src/piCli.js ADDED
@@ -0,0 +1,524 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createPiAgent, DEFAULT_LOCAL_MODEL_KEY, DEFAULT_MAX_NEW_TOKENS, LOCAL_MODELS } from "./piAgent.js";
2
+ import { PI_BUILTIN_TOOLS, PI_CLI_VERSION, PI_DEFAULT_TOOLS, PI_HELP_TEXT, PI_SLASH_COMMANDS, formatHotkeys } from "./piCliContract.js";
3
+
4
+ const ANSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
5
+
6
+ function stripAnsi(text) {
7
+ return String(text).replace(ANSI_PATTERN, "");
8
+ }
9
+
10
+ function textFromContent(content) {
11
+ if (typeof content === "string") return content;
12
+ if (!Array.isArray(content)) return "";
13
+ return content
14
+ .filter((part) => part.type === "text")
15
+ .map((part) => part.text)
16
+ .join("\n");
17
+ }
18
+
19
+ function toolCallsFromMessage(message) {
20
+ return Array.isArray(message.content) ? message.content.filter((part) => part.type === "toolCall") : [];
21
+ }
22
+
23
+ function formatToolCall(call) {
24
+ const args = call.arguments || {};
25
+ switch (call.name) {
26
+ case "bash":
27
+ return `$ ${args.command || ""}${args.timeout ? ` (timeout ${args.timeout}s)` : ""}`;
28
+ case "write":
29
+ return `write ${args.path || ""} (${String(args.content || "").length} bytes)`;
30
+ case "read":
31
+ return `read ${args.path || ""}${args.offset ? `:${args.offset}` : ""}${args.limit ? ` limit=${args.limit}` : ""}`;
32
+ case "edit":
33
+ return `edit ${args.path || ""} (${Array.isArray(args.edits) ? args.edits.length : 0} replacement(s))`;
34
+ case "grep":
35
+ return `grep ${args.pattern || ""} ${args.path || "."}`;
36
+ case "find":
37
+ return `find ${args.pattern || ""} ${args.path || "."}`;
38
+ case "ls":
39
+ return `ls ${args.path || "."}`;
40
+ default:
41
+ return `${call.name} ${JSON.stringify(args)}`;
42
+ }
43
+ }
44
+
45
+ function modelLabel(mode) {
46
+ if (mode === "mock") return "Deterministic test model";
47
+ return LOCAL_MODELS[mode]?.id || LOCAL_MODELS[DEFAULT_LOCAL_MODEL_KEY].id;
48
+ }
49
+
50
+ function shortModelName(mode) {
51
+ if (mode === "mock") return "mock";
52
+ return LOCAL_MODELS[mode]?.name || LOCAL_MODELS[DEFAULT_LOCAL_MODEL_KEY].name;
53
+ }
54
+
55
+ export function createPiCli({ terminal, sandbox, nodes, params }) {
56
+ let mode = "mock";
57
+ let device = "wasm";
58
+ let maxNewTokens = DEFAULT_MAX_NEW_TOKENS;
59
+ let temperature = 0;
60
+ let modelReady = false;
61
+ let status = "Ready";
62
+ let busy = false;
63
+ let input = "";
64
+ let renderedMessages = 0;
65
+ let outputText = "";
66
+ let sessionName = "untitled";
67
+ let sessionId = crypto.randomUUID();
68
+ let queuedLines = [];
69
+ let agent = null;
70
+
71
+ if (params.get("device")) device = params.get("device");
72
+ if (params.get("tokens")) maxNewTokens = Number(params.get("tokens")) || DEFAULT_MAX_NEW_TOKENS;
73
+ if (params.get("max_new_tokens")) maxNewTokens = Number(params.get("max_new_tokens")) || DEFAULT_MAX_NEW_TOKENS;
74
+ if (LOCAL_MODELS[params.get("model")]) mode = params.get("model");
75
+ if (LOCAL_MODELS[params.get("mode")]) mode = params.get("mode");
76
+ if (params.get("mode") === "mock") mode = "mock";
77
+ if (mode !== "mock" && !LOCAL_MODELS[mode]) mode = DEFAULT_LOCAL_MODEL_KEY;
78
+ if (!navigator.gpu && device === "webgpu") device = "wasm";
79
+ modelReady = mode === "mock";
80
+
81
+ function write(text) {
82
+ const value = String(text);
83
+ outputText += stripAnsi(value).replace(/\r/g, "");
84
+ if (outputText.length > 250000) outputText = outputText.slice(-200000);
85
+ terminal.write(value);
86
+ }
87
+
88
+ function writeln(text = "") {
89
+ write(`${text}\n`);
90
+ }
91
+
92
+ function setStatus(next) {
93
+ status = next;
94
+ nodes.status.textContent = next;
95
+ }
96
+
97
+ function setModelStatus(next) {
98
+ nodes.modelStatus.textContent = next;
99
+ nodes.modelLabel.textContent = modelLabel(mode);
100
+ }
101
+
102
+ function setSandboxStatus(next) {
103
+ nodes.sandboxStatus.textContent = next;
104
+ }
105
+
106
+ function createAgent() {
107
+ const next = createPiAgent({
108
+ sandbox,
109
+ modelMode: () => mode,
110
+ device: () => device,
111
+ maxTokens: () => maxNewTokens,
112
+ temperature: () => temperature,
113
+ onModelStatus: setModelStatus,
114
+ });
115
+ next.subscribe((event) => {
116
+ switch (event.type) {
117
+ case "agent_start":
118
+ setStatus("Agent running");
119
+ break;
120
+ case "tool_execution_start":
121
+ writeln(`\x1b[2m[running ${event.toolName}]\x1b[22m`);
122
+ break;
123
+ case "message_end":
124
+ renderNewMessages();
125
+ break;
126
+ case "agent_end":
127
+ setStatus("Ready");
128
+ break;
129
+ default:
130
+ break;
131
+ }
132
+ });
133
+ return next;
134
+ }
135
+
136
+ function resetAgent() {
137
+ agent?.abort();
138
+ agent = createAgent();
139
+ renderedMessages = 0;
140
+ }
141
+
142
+ async function bootSandbox({ quiet = false } = {}) {
143
+ if (!quiet) writeln("\x1b[2mBooting WebContainer sandbox...\x1b[22m");
144
+ await sandbox.boot();
145
+ if (!quiet) writeln("\x1b[2mSandbox ready.\x1b[22m");
146
+ }
147
+
148
+ function showGate(message = "Ready.") {
149
+ nodes.gateStatus.textContent = message;
150
+ nodes.modelGate.classList.remove("hidden");
151
+ }
152
+
153
+ function hideGate() {
154
+ nodes.modelGate.classList.add("hidden");
155
+ }
156
+
157
+ async function loadModel() {
158
+ if (mode === "mock") {
159
+ modelReady = true;
160
+ setModelStatus("Deterministic test model");
161
+ hideGate();
162
+ return;
163
+ }
164
+ busy = true;
165
+ setStatus("Loading model");
166
+ nodes.gateStatus.textContent = "Booting sandbox...";
167
+ writeln(`\x1b[2mPreparing ${modelLabel(mode)} on ${device}...\x1b[22m`);
168
+ try {
169
+ await bootSandbox({ quiet: true });
170
+ nodes.gateStatus.textContent = "Downloading model...";
171
+ await agent.preloadModel();
172
+ modelReady = true;
173
+ setModelStatus("Model ready");
174
+ nodes.gateStatus.textContent = "Model ready.";
175
+ hideGate();
176
+ writeln("\x1b[32mModel ready.\x1b[0m");
177
+ } catch (error) {
178
+ const message = error.stack || error.message || String(error);
179
+ setModelStatus("Model error");
180
+ nodes.gateStatus.textContent = message;
181
+ writeln(`\x1b[31m${message}\x1b[0m`);
182
+ } finally {
183
+ busy = false;
184
+ setStatus("Ready");
185
+ writePrompt();
186
+ }
187
+ }
188
+
189
+ function writeStartup() {
190
+ writeln(`pi - AI coding assistant with read, bash, edit, write tools`);
191
+ writeln(`version ${PI_CLI_VERSION} | web terminal ${terminal.engine} | cwd /workspace`);
192
+ writeln(`shortcuts: /hotkeys | /model | /settings | /session | /help`);
193
+ writeln(`loaded AGENTS.md: none | prompt templates: none | skills: none | extensions: none`);
194
+ writeln(`tools: ${PI_DEFAULT_TOOLS.join(", ")} (read-only extras available: grep, find, ls)`);
195
+ if (!modelReady) {
196
+ writeln(`model: ${modelLabel(mode)} is not downloaded. Use the setup dialog or /model mock.`);
197
+ } else {
198
+ writeln(`model: ${modelLabel(mode)}`);
199
+ }
200
+ writeln();
201
+ }
202
+
203
+ function footer() {
204
+ return `cwd /workspace | session ${sessionName} | model ${shortModelName(mode)} | ${maxNewTokens} max tokens | ${status}`;
205
+ }
206
+
207
+ function writePrompt() {
208
+ if (busy) return;
209
+ writeln(`\x1b[2m${footer()}\x1b[22m`);
210
+ write("> ");
211
+ terminal.focus();
212
+ }
213
+
214
+ function renderNewMessages() {
215
+ const messages = agent.state.messages;
216
+ while (renderedMessages < messages.length) {
217
+ const message = messages[renderedMessages];
218
+ renderedMessages += 1;
219
+ if (message.role === "user") continue;
220
+ if (message.role === "toolResult") {
221
+ const text = textFromContent(message.content);
222
+ const label = message.isError ? `[tool error: ${message.toolName}]` : `[tool: ${message.toolName}]`;
223
+ writeln(`\x1b[36m${label}\x1b[0m`);
224
+ if (text) writeln(text);
225
+ writeln();
226
+ continue;
227
+ }
228
+ const toolCalls = toolCallsFromMessage(message);
229
+ if (toolCalls.length > 0) {
230
+ writeln("\x1b[35mUsing pi tools:\x1b[0m");
231
+ for (const call of toolCalls) writeln(` ${formatToolCall(call)}`);
232
+ writeln();
233
+ continue;
234
+ }
235
+ const text = textFromContent(message.content);
236
+ if (text) {
237
+ writeln("\x1b[32mpi\x1b[0m");
238
+ writeln(text);
239
+ writeln();
240
+ }
241
+ }
242
+ }
243
+
244
+ async function runBashLine(command, sendToModel) {
245
+ busy = true;
246
+ setStatus("Running command");
247
+ try {
248
+ await bootSandbox({ quiet: true });
249
+ writeln(`$ ${command}`);
250
+ const result = await sandbox.bash(command, 120);
251
+ if (result.output) writeln(result.output);
252
+ writeln(`exit ${result.exitCode}`);
253
+ if (sendToModel) {
254
+ await promptAgent(`Command output from \`${command}\`:\n${result.output || "(no output)"}\nexit ${result.exitCode}`);
255
+ }
256
+ } catch (error) {
257
+ writeln(`\x1b[31m${error.stack || error.message || String(error)}\x1b[0m`);
258
+ } finally {
259
+ busy = false;
260
+ setStatus("Ready");
261
+ if (!sendToModel) writePrompt();
262
+ }
263
+ }
264
+
265
+ async function promptAgent(prompt) {
266
+ if (mode !== "mock" && !modelReady) {
267
+ writeln("Download the selected local model before sending, or use /model mock.");
268
+ showGate("Download the selected model before sending, or use the test model.");
269
+ writePrompt();
270
+ return;
271
+ }
272
+
273
+ busy = true;
274
+ setStatus("Agent running");
275
+ try {
276
+ await bootSandbox({ quiet: true });
277
+ await agent.prompt(prompt);
278
+ } catch (error) {
279
+ writeln(`\x1b[31m${error.stack || error.message || String(error)}\x1b[0m`);
280
+ } finally {
281
+ busy = false;
282
+ setStatus("Ready");
283
+ const next = queuedLines.shift();
284
+ if (next) {
285
+ writeln(`\x1b[2m[dequeued] ${next}\x1b[22m`);
286
+ await handleLine(next, { fromQueue: true });
287
+ } else {
288
+ writePrompt();
289
+ }
290
+ }
291
+ }
292
+
293
+ function printModels() {
294
+ writeln("Available browser-local models:");
295
+ for (const [key, value] of Object.entries(LOCAL_MODELS)) {
296
+ writeln(` ${key.padEnd(12)} ${value.id}`);
297
+ }
298
+ writeln(" mock deterministic test model");
299
+ writeln("Use /model <key> to switch. Non-mock models run in this browser through Transformers.js.");
300
+ }
301
+
302
+ function printSession() {
303
+ writeln(`Session`);
304
+ writeln(` id: ${sessionId}`);
305
+ writeln(` name: ${sessionName}`);
306
+ writeln(` messages: ${agent.state.messages.length}`);
307
+ writeln(` cwd: /workspace`);
308
+ writeln(` storage: browser memory`);
309
+ }
310
+
311
+ async function handleSlash(line) {
312
+ const [command, ...rest] = line.trim().split(/\s+/);
313
+ const argText = rest.join(" ");
314
+ switch (command) {
315
+ case "/help":
316
+ writeln(PI_HELP_TEXT);
317
+ break;
318
+ case "/hotkeys":
319
+ writeln(formatHotkeys());
320
+ break;
321
+ case "/model":
322
+ if (!argText) {
323
+ printModels();
324
+ break;
325
+ }
326
+ if (argText === "mock" || LOCAL_MODELS[argText]) {
327
+ mode = argText;
328
+ modelReady = mode === "mock";
329
+ resetAgent();
330
+ setModelStatus(modelReady ? "Deterministic test model" : "Model idle");
331
+ writeln(`model: ${modelLabel(mode)}`);
332
+ if (!modelReady) showGate("Download the selected model before sending.");
333
+ else hideGate();
334
+ } else {
335
+ writeln(`Unknown model: ${argText}`);
336
+ printModels();
337
+ }
338
+ break;
339
+ case "/settings":
340
+ if (argText.startsWith("device=")) {
341
+ device = argText.slice("device=".length) || device;
342
+ modelReady = mode === "mock";
343
+ resetAgent();
344
+ } else if (argText.startsWith("tokens=")) {
345
+ maxNewTokens = Number(argText.slice("tokens=".length)) || maxNewTokens;
346
+ } else if (argText.startsWith("temperature=")) {
347
+ temperature = Number(argText.slice("temperature=".length)) || 0;
348
+ }
349
+ writeln(`Settings`);
350
+ writeln(` device: ${device}`);
351
+ writeln(` max tokens: ${maxNewTokens}`);
352
+ writeln(` temperature: ${temperature}`);
353
+ writeln(` transport: browser-local Transformers.js`);
354
+ break;
355
+ case "/session":
356
+ printSession();
357
+ break;
358
+ case "/new":
359
+ resetAgent();
360
+ await sandbox.reset();
361
+ sessionId = crypto.randomUUID();
362
+ sessionName = "untitled";
363
+ writeln("Started a new browser session and reset the WebContainer workspace.");
364
+ break;
365
+ case "/name":
366
+ sessionName = argText || "untitled";
367
+ writeln(`Session name: ${sessionName}`);
368
+ break;
369
+ case "/tools":
370
+ writeln(`Default tools: ${PI_DEFAULT_TOOLS.join(", ")}`);
371
+ writeln(`Built-in tools: ${PI_BUILTIN_TOOLS.join(", ")}`);
372
+ break;
373
+ case "/clear":
374
+ terminal.clear();
375
+ outputText = "";
376
+ writeStartup();
377
+ break;
378
+ case "/login":
379
+ case "/logout":
380
+ writeln("This web port runs a local browser model, so provider login is not required. Use /model to switch local planners.");
381
+ break;
382
+ case "/resume":
383
+ case "/tree":
384
+ case "/fork":
385
+ case "/clone":
386
+ case "/compact":
387
+ case "/copy":
388
+ case "/export":
389
+ case "/share":
390
+ case "/reload":
391
+ case "/changelog":
392
+ case "/scoped-models":
393
+ writeln(`${command} is recognized from Pi CLI. In this static browser build it is represented in-memory; full filesystem-backed session management is a follow-up target.`);
394
+ break;
395
+ case "/quit":
396
+ writeln("Close this browser tab to quit the web terminal.");
397
+ break;
398
+ case "/commands":
399
+ writeln(PI_SLASH_COMMANDS.join("\n"));
400
+ break;
401
+ default:
402
+ writeln(`Unknown command: ${command}`);
403
+ writeln("Type /help or /commands.");
404
+ break;
405
+ }
406
+ }
407
+
408
+ async function handleLine(line, { fromQueue = false } = {}) {
409
+ const trimmed = line.trim();
410
+ if (!trimmed) {
411
+ writePrompt();
412
+ return;
413
+ }
414
+ if (busy && !fromQueue) {
415
+ queuedLines.push(line);
416
+ writeln(`\x1b[2m[queued] ${line}\x1b[22m`);
417
+ return;
418
+ }
419
+ if (trimmed.startsWith("/")) {
420
+ await handleSlash(trimmed);
421
+ writePrompt();
422
+ return;
423
+ }
424
+ if (trimmed.startsWith("!!")) {
425
+ await runBashLine(trimmed.slice(2).trim(), false);
426
+ return;
427
+ }
428
+ if (trimmed.startsWith("!")) {
429
+ await runBashLine(trimmed.slice(1).trim(), true);
430
+ return;
431
+ }
432
+ await promptAgent(line);
433
+ }
434
+
435
+ function redrawInput(nextInput) {
436
+ write("\r\x1b[2K> ");
437
+ input = nextInput;
438
+ write(input);
439
+ }
440
+
441
+ function handleData(data) {
442
+ for (const char of data) {
443
+ if (char === "\r") {
444
+ const submitted = input;
445
+ input = "";
446
+ writeln();
447
+ void handleLine(submitted);
448
+ } else if (char === "\u007f") {
449
+ if (input.length > 0) {
450
+ input = input.slice(0, -1);
451
+ write("\b \b");
452
+ }
453
+ } else if (char === "\x03") {
454
+ if (busy) {
455
+ agent.abort();
456
+ writeln("^C");
457
+ setStatus("Aborted");
458
+ } else if (input) {
459
+ redrawInput("");
460
+ } else {
461
+ writeln("^C");
462
+ writeln("Use /quit or close this browser tab to exit.");
463
+ writePrompt();
464
+ }
465
+ } else if (char === "\x1b") {
466
+ if (busy) agent.abort();
467
+ } else if (char >= " " && char !== "\x7f") {
468
+ input += char;
469
+ write(char);
470
+ }
471
+ }
472
+ }
473
+
474
+ nodes.confirmLoadModel.addEventListener("click", async () => {
475
+ device = nodes.gateDevice.value;
476
+ await loadModel();
477
+ });
478
+ nodes.useTestModel.addEventListener("click", () => {
479
+ mode = "mock";
480
+ modelReady = true;
481
+ resetAgent();
482
+ setModelStatus("Deterministic test model");
483
+ hideGate();
484
+ writeln("model: deterministic test model");
485
+ writePrompt();
486
+ });
487
+
488
+ resetAgent();
489
+ setStatus("Ready");
490
+ setSandboxStatus("Not booted");
491
+ setModelStatus(modelReady ? "Deterministic test model" : "Model idle");
492
+ nodes.gateDevice.value = device;
493
+ if (modelReady || params.get("setup") === "skip") hideGate();
494
+ else showGate("Ready.");
495
+
496
+ terminal.onData(handleData);
497
+ writeStartup();
498
+ writePrompt();
499
+
500
+ return {
501
+ loadModel,
502
+ handleLine,
503
+ get outputText() {
504
+ return outputText;
505
+ },
506
+ get transcript() {
507
+ return agent.state.messages
508
+ .map((message) => {
509
+ if (message.role === "toolResult") return `TOOL ${message.toolName}\n${textFromContent(message.content)}`;
510
+ return `${message.role.toUpperCase()}\n${textFromContent(message.content)}`;
511
+ })
512
+ .join("\n\n");
513
+ },
514
+ get modelReady() {
515
+ return modelReady;
516
+ },
517
+ get status() {
518
+ return status;
519
+ },
520
+ get mode() {
521
+ return mode;
522
+ },
523
+ };
524
+ }
src/piCliContract.js ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const PI_CLI_VERSION = "0.77.0";
2
+
3
+ export const PI_DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
4
+ export const PI_BUILTIN_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls"];
5
+
6
+ export const PI_SLASH_COMMANDS = [
7
+ "/login",
8
+ "/logout",
9
+ "/model",
10
+ "/scoped-models",
11
+ "/settings",
12
+ "/resume",
13
+ "/new",
14
+ "/name <name>",
15
+ "/session",
16
+ "/tree",
17
+ "/fork",
18
+ "/clone",
19
+ "/compact [prompt]",
20
+ "/copy",
21
+ "/export [file]",
22
+ "/share",
23
+ "/reload",
24
+ "/hotkeys",
25
+ "/changelog",
26
+ "/quit",
27
+ ];
28
+
29
+ export const PI_HELP_TEXT = `pi - AI coding assistant with read, bash, edit, write tools
30
+
31
+ Usage:
32
+ pi [options] [@files...] [messages...]
33
+
34
+ Commands:
35
+ pi install <source> [-l] Install extension source and add to settings
36
+ pi remove <source> [-l] Remove extension source from settings
37
+ pi uninstall <source> [-l] Alias for remove
38
+ pi update [source|self|pi] Update pi and installed extensions
39
+ pi list List installed extensions from settings
40
+ pi config Open TUI to enable/disable package resources
41
+ pi <command> --help Show help for install/remove/uninstall/update/list
42
+
43
+ Options:
44
+ --provider <name> Provider name (default: google)
45
+ --model <pattern> Model pattern or ID (supports "provider/id" and optional ":<thinking>")
46
+ --api-key <key> API key (defaults to env vars)
47
+ --system-prompt <text> System prompt (default: coding assistant prompt)
48
+ --append-system-prompt <text> Append text or file contents to the system prompt (can be used multiple times)
49
+ --mode <mode> Output mode: text (default), json, or rpc
50
+ --print, -p Non-interactive mode: process prompt and exit
51
+ --continue, -c Continue previous session
52
+ --resume, -r Select a session to resume
53
+ --session <path|id> Use specific session file or partial UUID
54
+ --session-id <id> Use exact project session ID, creating it if missing
55
+ --fork <path|id> Fork specific session file or partial UUID into a new session
56
+ --session-dir <dir> Directory for session storage and lookup
57
+ --no-session Don't save session (ephemeral)
58
+ --models <patterns> Comma-separated model patterns for Ctrl+P cycling
59
+ --no-tools, -nt Disable all tools by default (built-in and extension)
60
+ --no-builtin-tools, -nbt Disable built-in tools by default but keep extension/custom tools enabled
61
+ --tools, -t <tools> Comma-separated allowlist of tool names to enable
62
+ --exclude-tools, -xt <tools> Comma-separated denylist of tool names to disable
63
+ --thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
64
+ --extension, -e <path> Load an extension file (can be used multiple times)
65
+ --no-extensions, -ne Disable extension discovery (explicit -e paths still work)
66
+ --skill <path> Load a skill file or directory (can be used multiple times)
67
+ --prompt-template <path> Load a prompt template file or directory (can be used multiple times)
68
+ --theme <path> Load a theme file or directory (can be used multiple times)
69
+ --no-themes Disable theme discovery and loading
70
+ --no-context-files, -nc Disable AGENTS.md and CLAUDE.md discovery and loading
71
+ --export <file> Export session file to HTML and exit
72
+ --list-models [search] List available models (with optional fuzzy search)
73
+ --verbose Force verbose startup (overrides quietStartup setting)
74
+ --offline Disable startup network operations (same as PI_OFFLINE=1)
75
+ --help, -h Show this help
76
+ --version, -v Show version number
77
+
78
+ Built-in Tool Names:
79
+ read - Read file contents
80
+ bash - Execute bash commands
81
+ edit - Edit files with find/replace
82
+ write - Write files (creates/overwrites)
83
+ grep - Search file contents (read-only, off by default)
84
+ find - Find files by glob pattern (read-only, off by default)
85
+ ls - List directory contents (read-only, off by default)`;
86
+
87
+ export function formatHotkeys() {
88
+ return `Common Pi keybindings
89
+
90
+ Ctrl+C Clear editor
91
+ Ctrl+C twice Quit
92
+ Escape Cancel or abort
93
+ Escape twice Open /tree
94
+ Ctrl+L Open /model
95
+ Ctrl+P Cycle scoped models forward
96
+ Shift+Ctrl+P Cycle scoped models backward
97
+ Shift+Tab Cycle thinking level
98
+ Ctrl+O Collapse or expand tool output
99
+ Ctrl+T Collapse or expand thinking blocks
100
+ Shift+Enter Multi-line input`;
101
+ }
src/sandbox.js ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const BOOT_FILES = {
2
+ "package.json": {
3
+ file: {
4
+ contents: JSON.stringify(
5
+ {
6
+ type: "module",
7
+ scripts: {
8
+ demo: "node hello.js",
9
+ },
10
+ dependencies: {},
11
+ devDependencies: {},
12
+ },
13
+ null,
14
+ 2,
15
+ ),
16
+ },
17
+ },
18
+ "README.md": {
19
+ file: {
20
+ contents:
21
+ "# Pi web sandbox\n\nThis filesystem and every spawned process run inside WebContainers in this browser tab.\n",
22
+ },
23
+ },
24
+ "hello.js": {
25
+ file: {
26
+ contents: 'console.log("hello from the browser sandbox");\nconsole.log(2 + 2);\n',
27
+ },
28
+ },
29
+ };
30
+
31
+ const MAX_OUTPUT_CHARS = 16000;
32
+ const DEFAULT_TIMEOUT_MS = 10000;
33
+ const DEFAULT_MAX_READ_LINES = 2000;
34
+ const DEFAULT_RESULT_LIMIT = 200;
35
+
36
+ function splitCommandLine(value) {
37
+ const parts = [];
38
+ let current = "";
39
+ let quote = "";
40
+ let escaping = false;
41
+ for (const char of String(value || "")) {
42
+ if (escaping) {
43
+ current += char;
44
+ escaping = false;
45
+ continue;
46
+ }
47
+ if (char === "\\") {
48
+ escaping = true;
49
+ continue;
50
+ }
51
+ if (quote) {
52
+ if (char === quote) {
53
+ quote = "";
54
+ } else {
55
+ current += char;
56
+ }
57
+ continue;
58
+ }
59
+ if (char === "\"" || char === "'") {
60
+ quote = char;
61
+ } else if (/\s/.test(char)) {
62
+ if (current) {
63
+ parts.push(current);
64
+ current = "";
65
+ }
66
+ } else {
67
+ current += char;
68
+ }
69
+ }
70
+ if (current) parts.push(current);
71
+ return parts;
72
+ }
73
+
74
+ function escapeRegExp(value) {
75
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
76
+ }
77
+
78
+ function globToRegExp(glob) {
79
+ const source = String(glob || "*")
80
+ .split("*")
81
+ .map(escapeRegExp)
82
+ .join(".*");
83
+ return new RegExp(`^${source}$`);
84
+ }
85
+
86
+ export function createSandbox({ onLog = () => {}, onStatus = () => {} } = {}) {
87
+ let instance = null;
88
+ let booting = null;
89
+ let root = "";
90
+
91
+ async function boot() {
92
+ if (instance) return instance;
93
+ if (booting) return booting;
94
+
95
+ booting = (async () => {
96
+ if (!globalThis.crossOriginIsolated) {
97
+ throw new Error("WebContainers need cross-origin isolation. Start this app through Vite or another server that sends COOP/COEP headers.");
98
+ }
99
+
100
+ onStatus("Booting WebContainer");
101
+ const { WebContainer } = await import("@webcontainer/api");
102
+ instance = await WebContainer.boot({
103
+ coep: "credentialless",
104
+ workdirName: "workspace",
105
+ });
106
+ root = instance.workdir;
107
+ await instance.mount(BOOT_FILES);
108
+ onLog(`sandbox booted at ${root}`);
109
+ onStatus("Sandbox ready");
110
+ return instance;
111
+ })();
112
+
113
+ try {
114
+ return await booting;
115
+ } finally {
116
+ booting = null;
117
+ }
118
+ }
119
+
120
+ function assertRelativePath(path, { allowDot = false } = {}) {
121
+ let value = String(path ?? (allowDot ? "." : "")).trim();
122
+ if (value === "" && allowDot) value = ".";
123
+ value = value.replace(/^\/+/, "").replace(/^workspace\/?/, "");
124
+ if (value === "." && allowDot) return ".";
125
+ if (!value || value.includes("\0")) throw new Error("Path is required.");
126
+ const segments = value.split("/").filter(Boolean);
127
+ if (segments.some((segment) => segment === "..")) {
128
+ throw new Error("Paths must stay inside the sandbox workspace.");
129
+ }
130
+ const normalized = segments.filter((segment) => segment !== ".").join("/");
131
+ if (!normalized && allowDot) return ".";
132
+ if (!normalized) throw new Error("Path is required.");
133
+ return normalized;
134
+ }
135
+
136
+ function toWorkspacePath(path, options) {
137
+ return assertRelativePath(path, options);
138
+ }
139
+
140
+ async function pathExists(path) {
141
+ const wc = await boot();
142
+ try {
143
+ await wc.fs.stat(toWorkspacePath(path, { allowDot: true }));
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ async function reset() {
151
+ const wc = await boot();
152
+ const entries = await wc.fs.readdir(".");
153
+ await Promise.all(
154
+ entries.map((entry) =>
155
+ wc.fs.rm(entry, {
156
+ force: true,
157
+ recursive: true,
158
+ }),
159
+ ),
160
+ );
161
+ await wc.mount(BOOT_FILES);
162
+ onLog("sandbox reset");
163
+ return "Sandbox reset to the starter project.";
164
+ }
165
+
166
+ async function walkFiles(start = ".") {
167
+ const wc = await boot();
168
+ const rootPath = toWorkspacePath(start, { allowDot: true });
169
+ const files = [];
170
+
171
+ async function walk(current) {
172
+ const entries = await wc.fs.readdir(current, { withFileTypes: true });
173
+ for (const entry of entries) {
174
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
175
+ const child = current === "." ? entry.name : `${current}/${entry.name}`;
176
+ if (entry.isDirectory()) {
177
+ await walk(child);
178
+ } else {
179
+ files.push(child);
180
+ }
181
+ }
182
+ }
183
+
184
+ const stat = await wc.fs.stat(rootPath);
185
+ if (stat.isDirectory()) {
186
+ await walk(rootPath);
187
+ } else {
188
+ files.push(rootPath);
189
+ }
190
+ return files;
191
+ }
192
+
193
+ async function ls(path = ".", limit = DEFAULT_RESULT_LIMIT) {
194
+ const wc = await boot();
195
+ const target = toWorkspacePath(path || ".", { allowDot: true });
196
+ const stat = await wc.fs.stat(target);
197
+ if (!stat.isDirectory()) return `file ${target}`;
198
+ const entries = await wc.fs.readdir(target, { withFileTypes: true });
199
+ const capped = entries.slice(0, Number(limit || DEFAULT_RESULT_LIMIT));
200
+ const lines = capped.map((entry) => `${entry.isDirectory() ? "dir " : "file"} ${entry.name}`);
201
+ if (entries.length > capped.length) {
202
+ lines.push(`[Showing ${capped.length} of ${entries.length} entries. Use a larger limit to continue.]`);
203
+ }
204
+ return lines.join("\n") || "(empty)";
205
+ }
206
+
207
+ async function readTextFile(path, { offset, limit } = {}) {
208
+ const wc = await boot();
209
+ const target = toWorkspacePath(path);
210
+ const text = await wc.fs.readFile(target, "utf-8");
211
+ const lines = text.split("\n");
212
+ const start = Math.max(0, Number(offset || 1) - 1);
213
+ if (start >= lines.length) throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`);
214
+ const maxLines = Number(limit || DEFAULT_MAX_READ_LINES);
215
+ const selected = lines.slice(start, start + maxLines);
216
+ let output = selected.join("\n");
217
+ if (start + selected.length < lines.length) {
218
+ output += `\n\n[${lines.length - start - selected.length} more lines in file. Use offset=${start + selected.length + 1} to continue.]`;
219
+ }
220
+ return output;
221
+ }
222
+
223
+ async function writeFile(path, content) {
224
+ const wc = await boot();
225
+ const relative = assertRelativePath(path);
226
+ const parent = relative.split("/").slice(0, -1).join("/");
227
+ if (parent) {
228
+ await wc.fs.mkdir(parent, { recursive: true });
229
+ }
230
+ await wc.fs.writeFile(relative, String(content ?? ""));
231
+ onLog(`wrote ${relative}`);
232
+ return `Successfully wrote ${String(content ?? "").length} bytes to ${relative}`;
233
+ }
234
+
235
+ async function editFile(path, edits = []) {
236
+ const wc = await boot();
237
+ const target = toWorkspacePath(path);
238
+ if (!Array.isArray(edits) || edits.length === 0) {
239
+ throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
240
+ }
241
+ const original = await wc.fs.readFile(target, "utf-8");
242
+ const replacements = [];
243
+ for (const edit of edits) {
244
+ const oldText = String(edit?.oldText ?? "");
245
+ const newText = String(edit?.newText ?? "");
246
+ if (!oldText) throw new Error("Every edit.oldText must be non-empty.");
247
+ const first = original.indexOf(oldText);
248
+ if (first === -1) throw new Error(`Could not edit file: ${path}. oldText was not found.`);
249
+ if (original.indexOf(oldText, first + oldText.length) !== -1) {
250
+ throw new Error(`Could not edit file: ${path}. oldText must match exactly one location.`);
251
+ }
252
+ replacements.push({ start: first, end: first + oldText.length, newText });
253
+ }
254
+ replacements.sort((a, b) => b.start - a.start);
255
+ for (let index = 1; index < replacements.length; index += 1) {
256
+ if (replacements[index].end > replacements[index - 1].start) {
257
+ throw new Error("Edit tool input is invalid. edits must not overlap.");
258
+ }
259
+ }
260
+ let next = original;
261
+ for (const replacement of replacements) {
262
+ next = `${next.slice(0, replacement.start)}${replacement.newText}${next.slice(replacement.end)}`;
263
+ }
264
+ await wc.fs.writeFile(target, next);
265
+ onLog(`edited ${target}`);
266
+ return `Successfully replaced ${edits.length} block(s) in ${path}.`;
267
+ }
268
+
269
+ async function findFiles(pattern = "*", path = ".", limit = DEFAULT_RESULT_LIMIT) {
270
+ const start = toWorkspacePath(path || ".", { allowDot: true });
271
+ if (!(await pathExists(start))) throw new Error(`Path does not exist: ${path || "."}`);
272
+ const matcher = globToRegExp(pattern);
273
+ const files = await walkFiles(start);
274
+ const matches = files.filter((file) => matcher.test(file) || matcher.test(file.split("/").pop() || ""));
275
+ const capped = matches.slice(0, Number(limit || DEFAULT_RESULT_LIMIT));
276
+ if (matches.length > capped.length) capped.push(`[Showing ${capped.length} of ${matches.length} matches. Use a larger limit to continue.]`);
277
+ return capped.join("\n") || "(no matches)";
278
+ }
279
+
280
+ async function grepFiles({ pattern, path = ".", glob = "*", ignoreCase = false, literal = false, context = 0, limit = DEFAULT_RESULT_LIMIT }) {
281
+ if (!pattern) throw new Error("pattern is required.");
282
+ const flags = ignoreCase ? "i" : "";
283
+ const matcher = literal ? new RegExp(escapeRegExp(pattern), flags) : new RegExp(pattern, flags);
284
+ const globMatcher = globToRegExp(glob || "*");
285
+ const files = (await walkFiles(path || ".")).filter((file) => globMatcher.test(file) || globMatcher.test(file.split("/").pop() || ""));
286
+ const matches = [];
287
+ for (const file of files) {
288
+ let text = "";
289
+ try {
290
+ text = await readTextFile(file, { limit: DEFAULT_MAX_READ_LINES });
291
+ } catch {
292
+ continue;
293
+ }
294
+ const lines = text.split("\n");
295
+ for (let index = 0; index < lines.length; index += 1) {
296
+ if (!matcher.test(lines[index])) continue;
297
+ matcher.lastIndex = 0;
298
+ const before = Math.max(0, index - Number(context || 0));
299
+ const after = Math.min(lines.length - 1, index + Number(context || 0));
300
+ for (let lineIndex = before; lineIndex <= after; lineIndex += 1) {
301
+ matches.push(`${file}:${lineIndex + 1}:${lines[lineIndex]}`);
302
+ if (matches.length >= Number(limit || DEFAULT_RESULT_LIMIT)) {
303
+ matches.push(`[Showing first ${matches.length - 1} matches. Use a larger limit to continue.]`);
304
+ return matches.join("\n");
305
+ }
306
+ }
307
+ }
308
+ }
309
+ return matches.join("\n") || "(no matches)";
310
+ }
311
+
312
+ async function spawnProcess(command, args = [], timeoutMs = DEFAULT_TIMEOUT_MS) {
313
+ const wc = await boot();
314
+ const cmd = String(command || "").trim();
315
+ if (!cmd) throw new Error("Command is required.");
316
+ const cleanArgs = Array.isArray(args) ? args.map((arg) => String(arg)) : [];
317
+
318
+ onLog(`$ ${[cmd, ...cleanArgs].join(" ")}`);
319
+ const process = await wc.spawn(cmd, cleanArgs, {
320
+ terminal: {
321
+ cols: 96,
322
+ rows: 28,
323
+ },
324
+ });
325
+
326
+ let output = "";
327
+ const reader = process.output.getReader();
328
+ const pump = (async () => {
329
+ while (true) {
330
+ const { done, value } = await reader.read();
331
+ if (done) break;
332
+ output += value;
333
+ if (output.length > MAX_OUTPUT_CHARS) {
334
+ output = `${output.slice(0, MAX_OUTPUT_CHARS)}\n[output truncated]`;
335
+ process.kill();
336
+ break;
337
+ }
338
+ }
339
+ })();
340
+
341
+ const timeout = new Promise((resolve) => {
342
+ setTimeout(() => {
343
+ process.kill();
344
+ resolve("timeout");
345
+ }, Number(timeoutMs) || DEFAULT_TIMEOUT_MS);
346
+ });
347
+
348
+ const exitCode = await Promise.race([process.exit, timeout]);
349
+ await pump.catch(() => {});
350
+ const normalizedExitCode = exitCode === "timeout" ? 124 : exitCode;
351
+ const result = {
352
+ command: [cmd, ...cleanArgs].join(" "),
353
+ exitCode: normalizedExitCode,
354
+ output: output.trimEnd(),
355
+ };
356
+ onLog(`exit ${normalizedExitCode}`);
357
+ return result;
358
+ }
359
+
360
+ async function bash(command, timeoutSeconds) {
361
+ const timeoutMs = timeoutSeconds ? Number(timeoutSeconds) * 1000 : DEFAULT_TIMEOUT_MS;
362
+ try {
363
+ return await spawnProcess("jsh", ["-c", command], timeoutMs);
364
+ } catch (error) {
365
+ const parts = splitCommandLine(command);
366
+ if (parts.length === 0) throw error;
367
+ return await spawnProcess(parts[0], parts.slice(1), timeoutMs);
368
+ }
369
+ }
370
+
371
+ return {
372
+ boot,
373
+ reset,
374
+ bash,
375
+ editFile,
376
+ findFiles,
377
+ grepFiles,
378
+ ls,
379
+ listFiles: ls,
380
+ readFile: (path) => readTextFile(path),
381
+ readTextFile,
382
+ writeFile,
383
+ runCommand: spawnProcess,
384
+ get isReady() {
385
+ return Boolean(instance);
386
+ },
387
+ };
388
+ }
src/styles.css ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color: #d6deeb;
3
+ background: #0b0f16;
4
+ font-family:
5
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
6
+ }
7
+
8
+ * {
9
+ box-sizing: border-box;
10
+ }
11
+
12
+ html,
13
+ body,
14
+ #app {
15
+ min-width: 320px;
16
+ min-height: 100vh;
17
+ margin: 0;
18
+ }
19
+
20
+ button,
21
+ select {
22
+ font: inherit;
23
+ }
24
+
25
+ button {
26
+ min-height: 40px;
27
+ padding: 0 15px;
28
+ border: 0;
29
+ border-radius: 6px;
30
+ background: #227c70;
31
+ color: #ffffff;
32
+ font-weight: 750;
33
+ cursor: pointer;
34
+ }
35
+
36
+ button:disabled {
37
+ cursor: wait;
38
+ opacity: 0.6;
39
+ }
40
+
41
+ #app {
42
+ display: grid;
43
+ grid-template-rows: auto minmax(0, 1fr);
44
+ gap: 10px;
45
+ padding: 10px;
46
+ }
47
+
48
+ .runtime-bar {
49
+ display: flex;
50
+ align-items: end;
51
+ justify-content: space-between;
52
+ gap: 16px;
53
+ min-height: 54px;
54
+ padding: 4px 2px 10px;
55
+ border-bottom: 1px solid #263244;
56
+ }
57
+
58
+ h1,
59
+ h2,
60
+ p {
61
+ margin: 0;
62
+ }
63
+
64
+ h1 {
65
+ color: #f8fafc;
66
+ font-size: 30px;
67
+ line-height: 1;
68
+ letter-spacing: 0;
69
+ }
70
+
71
+ #model-label {
72
+ margin-top: 5px;
73
+ color: #9eb1ca;
74
+ font-size: 13px;
75
+ overflow-wrap: anywhere;
76
+ }
77
+
78
+ .status-stack {
79
+ display: flex;
80
+ flex-wrap: wrap;
81
+ justify-content: end;
82
+ gap: 8px;
83
+ max-width: 760px;
84
+ }
85
+
86
+ .status {
87
+ min-height: 32px;
88
+ max-width: 280px;
89
+ padding: 6px 10px;
90
+ border: 1px solid #344156;
91
+ border-radius: 6px;
92
+ background: #141b27;
93
+ color: #cad7ea;
94
+ font-size: 13px;
95
+ overflow-wrap: anywhere;
96
+ }
97
+
98
+ .terminal-shell {
99
+ min-height: 0;
100
+ border: 1px solid #263244;
101
+ border-radius: 8px;
102
+ background: #10141c;
103
+ overflow: hidden;
104
+ }
105
+
106
+ #terminal {
107
+ width: 100%;
108
+ height: calc(100vh - 86px);
109
+ min-height: 420px;
110
+ padding: 10px;
111
+ }
112
+
113
+ .xterm {
114
+ height: 100%;
115
+ }
116
+
117
+ .xterm-viewport,
118
+ .xterm-screen {
119
+ border-radius: 0;
120
+ }
121
+
122
+ .model-gate {
123
+ position: fixed;
124
+ inset: 0;
125
+ display: grid;
126
+ place-items: center;
127
+ padding: 18px;
128
+ background: rgba(4, 8, 14, 0.64);
129
+ z-index: 20;
130
+ }
131
+
132
+ .model-gate.hidden {
133
+ display: none;
134
+ }
135
+
136
+ .model-dialog {
137
+ display: grid;
138
+ gap: 16px;
139
+ width: min(560px, 100%);
140
+ padding: 22px;
141
+ border: 1px solid #334155;
142
+ border-radius: 8px;
143
+ background: #f8fafc;
144
+ color: #172033;
145
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
146
+ }
147
+
148
+ .eyebrow {
149
+ margin-bottom: 7px;
150
+ color: #227c70;
151
+ font-size: 12px;
152
+ font-weight: 850;
153
+ text-transform: uppercase;
154
+ }
155
+
156
+ .model-dialog h2 {
157
+ color: #172033;
158
+ font-size: 24px;
159
+ line-height: 1.15;
160
+ }
161
+
162
+ .dialog-copy {
163
+ margin-top: 8px;
164
+ color: #526173;
165
+ line-height: 1.5;
166
+ }
167
+
168
+ .dialog-actions {
169
+ display: flex;
170
+ flex-wrap: wrap;
171
+ gap: 10px;
172
+ }
173
+
174
+ #use-test-model {
175
+ background: #4f5f73;
176
+ }
177
+
178
+ .dialog-options {
179
+ display: grid;
180
+ gap: 10px;
181
+ color: #526173;
182
+ }
183
+
184
+ label {
185
+ display: grid;
186
+ gap: 6px;
187
+ font-size: 13px;
188
+ font-weight: 700;
189
+ }
190
+
191
+ select {
192
+ width: 100%;
193
+ height: 40px;
194
+ padding: 0 10px;
195
+ border: 1px solid #b8c5d4;
196
+ border-radius: 6px;
197
+ background: #ffffff;
198
+ color: #152033;
199
+ }
200
+
201
+ #gate-status {
202
+ min-height: 22px;
203
+ color: #526173;
204
+ overflow-wrap: anywhere;
205
+ }
206
+
207
+ @media (max-width: 760px) {
208
+ #app {
209
+ padding: 8px;
210
+ }
211
+
212
+ .runtime-bar {
213
+ align-items: stretch;
214
+ flex-direction: column;
215
+ }
216
+
217
+ .status-stack {
218
+ justify-content: start;
219
+ }
220
+
221
+ #terminal {
222
+ height: calc(100vh - 164px);
223
+ min-height: 360px;
224
+ padding: 8px;
225
+ }
226
+ }
src/webTerminal.js ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "@xterm/xterm/css/xterm.css";
2
+
3
+ const THEME = {
4
+ background: "#10141c",
5
+ foreground: "#d6deeb",
6
+ cursor: "#f7c948",
7
+ cursorAccent: "#10141c",
8
+ selectionBackground: "#334155",
9
+ black: "#111827",
10
+ red: "#f87171",
11
+ green: "#34d399",
12
+ yellow: "#facc15",
13
+ blue: "#60a5fa",
14
+ magenta: "#c084fc",
15
+ cyan: "#22d3ee",
16
+ white: "#f8fafc",
17
+ brightBlack: "#475569",
18
+ brightRed: "#fca5a5",
19
+ brightGreen: "#86efac",
20
+ brightYellow: "#fde68a",
21
+ brightBlue: "#93c5fd",
22
+ brightMagenta: "#d8b4fe",
23
+ brightCyan: "#67e8f9",
24
+ brightWhite: "#ffffff",
25
+ };
26
+
27
+ function normalizeNewlines(text) {
28
+ return String(text).replace(/\r?\n/g, "\r\n");
29
+ }
30
+
31
+ function withTimeout(promise, ms, label) {
32
+ return Promise.race([
33
+ promise,
34
+ new Promise((_, reject) => {
35
+ setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
36
+ }),
37
+ ]);
38
+ }
39
+
40
+ export async function createWebTerminal(container) {
41
+ let terminal;
42
+ let fitAddon;
43
+ let engine = "ghostty-web";
44
+
45
+ try {
46
+ const ghostty = await withTimeout(import("ghostty-web"), 3000, "ghostty-web import");
47
+ await withTimeout(ghostty.init(), 3000, "ghostty-web init");
48
+ terminal = new ghostty.Terminal({
49
+ cursorBlink: true,
50
+ fontFamily: "JetBrains Mono, SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace",
51
+ fontSize: 14,
52
+ rows: 30,
53
+ cols: 100,
54
+ scrollback: 5000,
55
+ theme: THEME,
56
+ });
57
+ fitAddon = new ghostty.FitAddon();
58
+ terminal.loadAddon(fitAddon);
59
+ } catch (error) {
60
+ const [{ Terminal }, { FitAddon }] = await Promise.all([import("@xterm/xterm"), import("@xterm/addon-fit")]);
61
+ engine = "xterm";
62
+ terminal = new Terminal({
63
+ cursorBlink: true,
64
+ fontFamily: "JetBrains Mono, SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace",
65
+ fontSize: 14,
66
+ rows: 30,
67
+ cols: 100,
68
+ scrollback: 5000,
69
+ theme: THEME,
70
+ });
71
+ fitAddon = new FitAddon();
72
+ terminal.loadAddon(fitAddon);
73
+ console.warn("ghostty-web failed, using xterm fallback", error);
74
+ }
75
+
76
+ terminal.open(container);
77
+ fitAddon.fit();
78
+ terminal.focus();
79
+
80
+ const resizeObserver = new ResizeObserver(() => fitAddon.fit());
81
+ resizeObserver.observe(container);
82
+
83
+ return {
84
+ engine,
85
+ raw: terminal,
86
+ onData: (handler) => terminal.onData(handler),
87
+ write: (text) => terminal.write(normalizeNewlines(text)),
88
+ writeln: (text = "") => terminal.write(`${normalizeNewlines(text)}\r\n`),
89
+ clear: () => terminal.clear(),
90
+ focus: () => terminal.focus(),
91
+ fit: () => fitAddon.fit(),
92
+ dispose: () => {
93
+ resizeObserver.disconnect();
94
+ terminal.dispose();
95
+ },
96
+ };
97
+ }
vite.config.js ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+
3
+ const headers = {
4
+ "Cross-Origin-Embedder-Policy": "credentialless",
5
+ "Cross-Origin-Opener-Policy": "same-origin",
6
+ };
7
+
8
+ export default defineConfig({
9
+ server: {
10
+ headers,
11
+ },
12
+ preview: {
13
+ headers,
14
+ },
15
+ });
16
+