@joystack/cli 0.1.55 → 0.1.56

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.
@@ -1,7 +1,6 @@
1
- // The hub's credential for the agent: each running dev server writes its port
1
+ // The relay's credential for the agent: each running dev server writes its port
2
2
  // and a bearer token to .joystack/relay/.connection-<port>, owner-only, so
3
- // only a process that can read the game folder can publish or tail as the
4
- // agent. `joystack relay` reads every file there and speaks to each server.
3
+ // only a process that can read the game folder can reply as the agent. `joystack relay` reads every file there and speaks to each server.
5
4
  import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
6
5
  import { join } from 'node:path';
7
6
  import { stateDir } from '../legacy-names.mjs';
@@ -1,21 +1,22 @@
1
- // The hub's own listener for the laptop conversation: a `note.sent` event
2
- // (the note in shared/ui/note/, once the person pressed Send) becomes files
3
- // the agent can open under .joystack/relay/ and a message in the Claude Code
4
- // session that started this dev server, through that session's inbox socket
5
- // (agent-session.mjs). Installed only when a session started the server; a
6
- // server from a plain terminal has no such listener, and the maker's dev
7
- // server has none either (its conversation is the maker page's).
1
+ // The laptop conversation: a note the person sent (shared/ui/note/, once
2
+ // they pressed Send) becomes files the agent can open under .joystack/relay/
3
+ // and a message in the Claude Code session that started this dev server,
4
+ // through that session's inbox socket (agent-session.mjs). Made only when a
5
+ // session started the server; a server from a plain terminal has none, and
6
+ // the maker's dev server has none either (its conversation is the maker
7
+ // page's).
8
8
  //
9
- // Reaching the agent starts paid work, so only a note the person sent from
10
- // the creator's own browser wakes it: the hub stamps `from.creator`, and a
11
- // page a phone on the wifi or a --share tester loaded is never the creator's.
9
+ // Reaching the agent starts paid work, so only a note the creator's own
10
+ // browser sent reaches here: the relay's route refuses any other page
11
+ // (relay.mjs), and a page a phone on the wifi or a --share tester
12
+ // loaded is never the creator's.
12
13
  import { randomBytes } from 'node:crypto';
13
14
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
14
15
  import { join } from 'node:path';
15
16
  import { postToSession, sessionAlive } from '../agent-session.mjs';
16
17
  import { relayDir } from './connection.mjs';
17
18
 
18
- // A page can publish in a loop. The agent is woken at most this often: a note
19
+ // A page can send notes in a loop. The agent is woken at most this often: a note
19
20
  // sent sooner waits its turn, and once this many are waiting, the rest are
20
21
  // filed without waking it (a burst that size is a script, not someone typing).
21
22
  export const WAKE_MIN_INTERVAL_MS = 1000;
@@ -41,12 +42,12 @@ export function shellWord(text) {
41
42
  // so no line of it can read as one the server wrote (a second header, a
42
43
  // different reply command).
43
44
  const LINE_BREAK_RE = /\r\n?|[\u0085\u2028\u2029]/g;
44
- const asLines = (text) => String(text).replace(LINE_BREAK_RE, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n');
45
+ const asLines = (text) => String(text).replace(LINE_BREAK_RE, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u0084\u0086-\u009f]/g, '').split('\n');
45
46
  const indented = (text) => asLines(text).map((l) => ` ${l}`).join('\n');
46
47
  // A label is one line: JSON.stringify escapes \n and \r but not the Unicode
47
48
  // separators, so they are made spaces first.
48
49
  const oneLine = (text) => asLines(text).join(' ');
49
- // The kind of thing attached is an event name (relay.mjs NAME_RE) or nothing.
50
+ // The kind of thing attached is a relay name (`<owner>.<what>`) or nothing.
50
51
  const KIND_RE = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)+$/;
51
52
 
52
53
  // The message that wakes the agent. Everything the server writes comes
@@ -58,8 +59,7 @@ export function agentMessage({ kind, label, words, text, file, image, id, projec
58
59
  if (words) lines.push(indented(words));
59
60
  lines.push(`Data: ${file}`);
60
61
  if (image) lines.push(`Picture of what they see: ${image}`);
61
- const reply = JSON.stringify({ to: id, text: '<what you did>' });
62
- lines.push(`Answer where they asked, in one or two sentences: node ${shellWord(cliBin)} relay publish --dir ${shellWord(projectDir)} agent.replied ${shellWord(reply)}`);
62
+ lines.push(`Answer where they asked, in one or two sentences: node ${shellWord(cliBin)} relay reply --dir ${shellWord(projectDir)} ${id} '<what you did>'`);
63
63
  lines.push('', ...asLines(String(text).trim()).map((line) => `> ${line}`));
64
64
  return lines.join('\n');
65
65
  }
@@ -0,0 +1,15 @@
1
+ // The folder of the engine's relay the dev server loads its pure files from:
2
+ // the game's installed engine, or this CLI's own repository when it runs
3
+ // from inside it. Null when that engine has no relay with `files` in it.
4
+ import { existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ export function relayFiles(sharedDir, files) {
8
+ // The repository's own relay only from a checkout of it: an installed CLI
9
+ // sits in node_modules, where the same path names a package called
10
+ // `shared`.
11
+ const repo = join(import.meta.dirname, '../../../..');
12
+ const fromRepo = existsSync(join(repo, 'packages', 'cli', 'package.json')) ? join(repo, 'shared', 'ui', 'relay') : null;
13
+ const candidates = [sharedDir && join(sharedDir, 'ui', 'relay'), fromRepo];
14
+ return candidates.find((dir) => dir && files.every((f) => existsSync(join(dir, f)))) ?? null;
15
+ }
@@ -0,0 +1,57 @@
1
+ // The relay's HTTP pieces on the dev server: answers, a bounded body, and
2
+ // the agent's bearer token.
3
+ import { timingSafeEqual } from 'node:crypto';
4
+
5
+ // What one post may weigh: a pick carries its picture.
6
+ export const MAX_BYTES = 8 * 1024 * 1024;
7
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
8
+
9
+ export const json = (res, status, body, extra = {}) => {
10
+ const buf = Buffer.from(JSON.stringify(body));
11
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': buf.length, 'Cache-Control': 'no-store', ...extra });
12
+ res.end(buf);
13
+ };
14
+ export const text = (res, status, body, extra = {}) => {
15
+ res.writeHead(status, { 'Content-Type': 'text/plain', ...extra });
16
+ res.end(body);
17
+ };
18
+ // The agent's credential: a bearer token from a process on this machine,
19
+ // never a browser (which always sends Origin on a cross-origin request and
20
+ // cannot set this header from a page the server did not serve). 'agent'
21
+ // when it is the server's token, 'refused' when a bearer was offered and is
22
+ // not, null when none was offered.
23
+ export function bearer(req, token) {
24
+ const header = String(req.headers.authorization ?? '');
25
+ if (!header.startsWith('Bearer ')) return null;
26
+ const supplied = Buffer.from(header.slice(7));
27
+ const expected = Buffer.from(token);
28
+ const ok = LOOPBACK.has(req.socket.remoteAddress) && !req.headers.origin
29
+ && supplied.length === expected.length && timingSafeEqual(supplied, expected);
30
+ return ok ? 'agent' : 'refused';
31
+ }
32
+
33
+ export function readBody(req, res, maxBytes) {
34
+ return new Promise((resolve) => {
35
+ const chunks = [];
36
+ let size = 0;
37
+ let overflowed = false;
38
+ req.on('data', (chunk) => {
39
+ if (overflowed) return; // draining the rest so the client can read the 413
40
+ size += chunk.length;
41
+ if (size > maxBytes) {
42
+ overflowed = true;
43
+ chunks.length = 0;
44
+ text(res, 413, `message too large (max ${maxBytes} bytes)`, { Connection: 'close' });
45
+ return;
46
+ }
47
+ chunks.push(chunk);
48
+ });
49
+ req.on('end', () => resolve(overflowed ? null : Buffer.concat(chunks).toString('utf8')));
50
+ // A client that goes before the body ends is answered by nobody; the
51
+ // promise still settles, so nothing waits on it for good.
52
+ req.on('close', () => { if (!req.complete) resolve(null); });
53
+ req.on('error', () => resolve(null));
54
+ });
55
+ }
56
+ export const parse = (raw) => { try { return JSON.parse(raw); } catch { return undefined; } };
57
+