@looop-games/cli 0.1.31 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,32 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.33] - 2026-08-27
18
+
19
+ ### Added
20
+ - **The dev server now carries an agent inbox** — `POST /__looop/agent-inbox`.
21
+ Dev tools in the running game (starting with the Performance tool's "Send to
22
+ agent" button) can ship a JSON report out of the browser; it lands as one
23
+ file under your game's `.looop/agent-inbox/` and prints one line in the
24
+ `looop dev` terminal. That folder is where your agent looks when you send it
25
+ something from inside the game — it ignores itself in git, so reports never
26
+ clutter `git status`. Only pages served by your own dev server can write to
27
+ it (cross-origin posts from other websites are refused), and single reports
28
+ are capped at 2 MB. There's no cleanup/rotation yet — reports are small and
29
+ yours to delete.
30
+
31
+ ## [0.1.32] - 2026-08-23
32
+
33
+ ### Fixed
34
+ - `looop dev` no longer hands your game a **browser-blocked port**. It derives the
35
+ multiplayer and services ports from the game port, and a few combinations land
36
+ on ports browsers refuse to connect to (`ERR_UNSAFE_PORT` — e.g. game port 8050
37
+ derives multiplayer port 2049/NFS). The game booted but its room socket died
38
+ instantly, so it rendered blank with no owned player — and because `curl` and
39
+ headless tests ignore the blocklist, every check looked healthy. Dev now skips
40
+ any port block a browser can't reach, and warns if you pin an unreachable one
41
+ with `--port`.
42
+
17
43
  ## [0.1.31] - 2026-08-21
18
44
 
19
45
  ### Changed
package/lib/dev.mjs CHANGED
@@ -16,7 +16,7 @@ import { ensureEngine } from './engine.mjs';
16
16
  import { createStaticServer } from './static-server.mjs';
17
17
  import { createLlmShim, DEFAULT_API_BASE } from './llm-shim.mjs';
18
18
  import { getToken, getApiBase } from './config.mjs';
19
- import { resolvePorts, portInUse, killPort, lanIp } from './ports.mjs';
19
+ import { resolvePorts, portInUse, killPort, lanIp, isRestricted, nextBrowserSafe } from './ports.mjs';
20
20
  import { assertNoInertOverride, isFrameworkV2, isSinglePlayerV2, scanPrimitives } from './primitives.mjs';
21
21
  import { buildDevRoomServer, DEV_OVERRIDES_DIR } from './room-server.mjs';
22
22
  import { createFileWatcher, createRoomReloader } from './room-reload.mjs';
@@ -73,8 +73,18 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
73
73
  requested: explicit,
74
74
  });
75
75
  for (const s of steppedAside) {
76
- const who = s.projectDir ? `another Looop game (${basename(s.projectDir)})` : 'another app';
77
- log(`→ :${s.port} (held by ${who}leaving it alone)`);
76
+ const who = s.reason === 'browser-restricted'
77
+ ? 'browser-restricted (mp/shim would be blocked) stepping past'
78
+ : `held by ${s.projectDir ? `another Looop game (${basename(s.projectDir)})` : 'another app'} — leaving it alone`;
79
+ log(`→ :${s.port} (${who})`);
80
+ }
81
+ // An EXPLICIT --port is obeyed as given, but if it derives a browser-blocked
82
+ // port (e.g. --port 8050 → mp 2049/NFS) the game would look broken with no
83
+ // clue, so say so loudly rather than silently binding it.
84
+ for (const [name, p] of [['multiplayer', ports.mp], ['services', ports.shim], ['static', ports.static]]) {
85
+ if (isRestricted(p)) {
86
+ log(`⚠ ${name} port :${p} is browser-restricted (ERR_UNSAFE_PORT) — browsers will refuse it. Re-run with a --port whose ${name} port is reachable (e.g. --port ${nextBrowserSafe(ports.static)}).`);
87
+ }
78
88
  }
79
89
  const children = [];
80
90
  const servers = [];
@@ -135,6 +145,7 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
135
145
  const staticServer = createStaticServer({
136
146
  slug: project.slug,
137
147
  projectDir: project.dir, // answers /__looop/whoami — see ports.mjs
148
+ log, // agent-inbox report lines ride the dev logger
138
149
  mounts: [
139
150
  { url: `/games/${project.slug}/`, dir: project.dir },
140
151
  { url: '/shared/', dir: generatedOverridesDir },
package/lib/ports.mjs CHANGED
@@ -35,6 +35,40 @@ export const STATIC_PORT_BASE = 8000;
35
35
  export const MP_PORT_BASE = 1999;
36
36
  export const SHIM_PORT_BASE = 8788;
37
37
 
38
+ // Ports a BROWSER refuses to connect to (ERR_UNSAFE_PORT), from Chromium's
39
+ // net/base/port_util.cc `kRestrictedPorts` (Firefox bans the same set). We
40
+ // derive mp/shim from the static port, so a block landing ANY of the three on
41
+ // one of these silently breaks the browser connection — while curl and a
42
+ // headless probe connect fine, so every server-side check looks healthy. The
43
+ // one that bites in the normal range is 2049 (NFS): static 8050 → mp 2049. The
44
+ // full set keeps it robust if the derivation range ever shifts. Reported by a
45
+ // creator (feedback fb_c690e35cff30511e).
46
+ export const RESTRICTED_PORTS = new Set([
47
+ 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79,
48
+ 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137,
49
+ 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532,
50
+ 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723,
51
+ 2049, 3659, 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697,
52
+ 10080,
53
+ ]);
54
+
55
+ export function isRestricted(port) {
56
+ return RESTRICTED_PORTS.has(port);
57
+ }
58
+
59
+ /** True if a static base derives a block where static, mp, OR shim is browser-restricted. */
60
+ export function blockIsRestricted(staticPort) {
61
+ const p = portsFor(staticPort);
62
+ return isRestricted(p.static) || isRestricted(p.mp) || isRestricted(p.shim);
63
+ }
64
+
65
+ /** The first static port ≥ `staticPort` whose whole derived block is browser-reachable. */
66
+ export function nextBrowserSafe(staticPort) {
67
+ let p = staticPort;
68
+ while (blockIsRestricted(p)) p += 1;
69
+ return p;
70
+ }
71
+
38
72
  // Ports move in BLOCKS: the browser derives mp/shim from location.port, so the
39
73
  // whole stack shifts together. 8000/1999/8788 → 8010/2009/8798 → …
40
74
  export const BLOCK_STRIDE = 10;
@@ -97,6 +131,15 @@ export async function resolvePorts({
97
131
  const steppedAside = [];
98
132
  for (let i = 0; i < blocks; i++) {
99
133
  const ports = portsFor(base + i * BLOCK_STRIDE);
134
+ // A block whose static/mp/shim lands on a browser-restricted port is
135
+ // unusable no matter how free it is — the browser refuses the connection
136
+ // (ERR_UNSAFE_PORT) even though curl and server checks pass. Skip it before
137
+ // probing, so auto-allocation only ever hands the creator a browser-
138
+ // reachable stack. (feedback fb_c690e35cff30511e — mp 2049/NFS on base 8050.)
139
+ if (blockIsRestricted(base + i * BLOCK_STRIDE)) {
140
+ steppedAside.push({ port: ports.static, projectDir: null, reason: 'browser-restricted' });
141
+ continue;
142
+ }
100
143
  const owner = (await portInUse(ports.static)) ? await whoIsOn(ports.static) : undefined;
101
144
 
102
145
  if (owner === undefined) {
@@ -10,7 +10,7 @@
10
10
  // overrides dir mounts at /shared/ ahead of the bundle.
11
11
  import http from 'node:http';
12
12
  import { EventEmitter } from 'node:events';
13
- import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
13
+ import { readdirSync, readFileSync, statSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
14
14
  import { extname, join, normalize, sep } from 'node:path';
15
15
  import { injectHeadTags, rewriteHtmlScripts, rewriteJsImports, PLATFORM_URL, TOOLBOX_URL } from './inject.mjs';
16
16
  import { WHOAMI_PATH } from './ports.mjs';
@@ -34,6 +34,12 @@ const RELOAD_CLIENT = `<script>
34
34
 
35
35
  const WATCH_EXTS = new Set(['.html', '.css', '.js', '.mjs', '.json', '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp']);
36
36
 
37
+ // The toolbox → agent report channel (see handleAgentInbox below). The path is
38
+ // a contract with the toolbox shell's ctx.toAgent(); the cap keeps a runaway
39
+ // payload from filling the disk — reports are small JSON summaries.
40
+ export const AGENT_INBOX_PATH = '/__looop/agent-inbox';
41
+ const AGENT_INBOX_MAX_BYTES = 2 * 1024 * 1024;
42
+
37
43
  const MIME = {
38
44
  '.html': 'text/html; charset=utf-8',
39
45
  '.js': 'text/javascript; charset=utf-8',
@@ -102,6 +108,7 @@ export function createStaticServer({
102
108
  injectReload = true,
103
109
  watchIntervalMs = 400,
104
110
  identity,
111
+ log = console.log,
105
112
  // A framework-v2 game's lowered skeleton — injected into the entry HTML as an
106
113
  // import map + globalThis.__LOOOP_SKELETON__ so startGame() can boot. Null for
107
114
  // a v1 game (no bare `looop` import, no skeleton).
@@ -234,10 +241,91 @@ export function createStaticServer({
234
241
  });
235
242
  }
236
243
 
244
+ // The agent inbox — the write half of the toolbox → agent channel (project
245
+ // note nf7zwa). A dev tool in the running game POSTs an envelope
246
+ // ({ tool, at, payload }, stamped by the toolbox shell); it lands as ONE file
247
+ // in <projectDir>/.looop/agent-inbox/ and prints one summary line. The file
248
+ // is the load-bearing sink: the agent working on this game usually does NOT
249
+ // hold the dev-server terminal, so stdout alone would be invisible to it.
250
+ // The mailbox dir ignores itself (a `.gitignore` of `*` inside it), so
251
+ // reports never show up in the creator's `git status` whatever repo shape
252
+ // the game has.
253
+ function handleAgentInbox(req, res) {
254
+ if (req.method !== 'POST') {
255
+ res.writeHead(405, { 'Content-Type': 'text/plain', Allow: 'POST' });
256
+ return res.end('POST only');
257
+ }
258
+ // Only the page this server itself served may write. A hostile website in
259
+ // the creator's browser can fire a no-preflight POST at localhost (a CORS
260
+ // "simple" content-type skips the preflight, and CORS only gates reading
261
+ // the response, never the server-side write) — and these files are later
262
+ // read by an AI agent as trusted tool reports, so a foreign page must not
263
+ // be able to author them. Browsers send Origin on every POST; the real
264
+ // client is served from this server, so its Origin equals our host.
265
+ // Non-browser callers (curl, node) send no Origin and pass — same trust
266
+ // level as anything else already running on the creator's machine/LAN.
267
+ const origin = req.headers.origin;
268
+ if (origin && origin !== `http://${req.headers.host}`) {
269
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
270
+ return res.end('cross-origin post rejected');
271
+ }
272
+ const chunks = [];
273
+ let size = 0;
274
+ let overflowed = false;
275
+ req.on('data', (chunk) => {
276
+ if (overflowed) return; // draining the rest so the client can read the 413
277
+ size += chunk.length;
278
+ if (size > AGENT_INBOX_MAX_BYTES) {
279
+ overflowed = true;
280
+ chunks.length = 0;
281
+ res.writeHead(413, { 'Content-Type': 'text/plain', Connection: 'close' });
282
+ res.end(`report too large (max ${AGENT_INBOX_MAX_BYTES} bytes)`);
283
+ return;
284
+ }
285
+ chunks.push(chunk);
286
+ });
287
+ req.on('end', () => {
288
+ if (overflowed) return;
289
+ let envelope;
290
+ try {
291
+ envelope = JSON.parse(Buffer.concat(chunks).toString('utf8'));
292
+ } catch {
293
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
294
+ return res.end('body must be JSON');
295
+ }
296
+ // The tool id names the file — it must be a plain slug, never a path.
297
+ const tool = envelope?.tool;
298
+ if (typeof tool !== 'string' || !/^[a-z0-9][a-z0-9-]{0,39}$/i.test(tool)) {
299
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
300
+ return res.end('envelope needs a tool id ([a-z0-9-])');
301
+ }
302
+ try {
303
+ const inboxDir = join(projectDir, '.looop', 'agent-inbox');
304
+ mkdirSync(inboxDir, { recursive: true });
305
+ const selfIgnore = join(inboxDir, '.gitignore');
306
+ if (!existsSync(selfIgnore)) writeFileSync(selfIgnore, '*\n');
307
+ const d = new Date();
308
+ const p = (n, w = 2) => String(n).padStart(w, '0');
309
+ const stamp = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}-${p(d.getMilliseconds(), 3)}`;
310
+ let file = join(inboxDir, `${stamp}-${tool}.json`);
311
+ for (let n = 2; existsSync(file); n += 1) file = join(inboxDir, `${stamp}-${tool}-${n}.json`);
312
+ writeFileSync(file, JSON.stringify(envelope, null, 2) + '\n');
313
+ log(`agent-inbox: ${tool} (${(size / 1024).toFixed(1)} KB) → ${file}`);
314
+ sendBody(res, 200, JSON.stringify({ ok: true, file }), 'application/json');
315
+ } catch (err) {
316
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
317
+ res.end(`agent-inbox write failed: ${err?.message ?? err}`);
318
+ }
319
+ });
320
+ }
321
+
237
322
  const server = http.createServer((req, res) => {
238
323
  const reqUrl = new URL(req.url, 'http://x');
239
324
  const urlPath = reqUrl.pathname;
240
325
  if (urlPath === '/__reload') return handleSse(res);
326
+ // Without a projectDir there is nowhere to land a report — the route
327
+ // simply doesn't exist (falls through to 404), same as any unknown path.
328
+ if (urlPath === AGENT_INBOX_PATH && projectDir) return handleAgentInbox(req, res);
241
329
  // Ownership probe (ports.mjs): "whose dev server is this?". It is what lets
242
330
  // another `looop dev` — a lane, another game — tell OUR stale server (kill
243
331
  // and reclaim) from a live one that belongs to somebody else (step around,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "description": "Looop game development CLI — dev server, login, and publishing for standalone Looop games.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",