pennyrouter 0.3.40 → 0.3.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.40",
3
+ "version": "0.3.42",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -0,0 +1,92 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="referrer" content="no-referrer">
7
+ <title>Demo artifact preview · Penny Compare</title>
8
+ <style>
9
+ :root { color-scheme: dark; font: 14px/1.4 system-ui, sans-serif; }
10
+ * { box-sizing: border-box; }
11
+ html, body { margin: 0; height: 100%; background: #111116; color: #e3e3e8; }
12
+ body { display: flex; flex-direction: column; }
13
+ header { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 12px 16px;
14
+ background: #24242b; border-bottom: 1px solid #44444d; }
15
+ strong { color: #9ded83; }
16
+ #filename { overflow-wrap: anywhere; flex: 1; }
17
+ button { font: inherit; color: inherit; background: transparent; border: 1px solid #74747c;
18
+ padding: 5px 12px; cursor: pointer; }
19
+ button:disabled { opacity: .45; cursor: default; }
20
+ #status { margin: 0; padding: 8px 16px; color: #b8b8c3; }
21
+ #status:empty { display: none; }
22
+ iframe { flex: 1; width: 100%; min-height: 0; border: 0; background: white; }
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <header><strong id="side">PENNY COMPARE</strong><span id="filename">Artifact preview</span>
27
+ <button id="refresh" disabled>Refresh</button><button id="download" disabled>Download</button></header>
28
+ <p id="status" role="status">Connecting to your demo…</p>
29
+ <iframe id="preview" title="Generated HTML preview" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe>
30
+ <script>
31
+ let session;
32
+ let content;
33
+ let loading = false;
34
+ const status = document.getElementById('status');
35
+ const refresh = document.getElementById('refresh');
36
+ const download = document.getElementById('download');
37
+ async function load() {
38
+ if (!session || loading) return;
39
+ loading = true;
40
+ refresh.disabled = true;
41
+ status.textContent = 'Loading…';
42
+ try {
43
+ const params = new URLSearchParams({ side: session.side, path: session.path });
44
+ const response = await fetch(`/artifact?${params}`, {
45
+ headers: { Authorization: `Bearer ${session.token}` }, cache: 'no-store',
46
+ signal: AbortSignal.timeout(15000),
47
+ });
48
+ if (!response.ok) throw new Error('File is unavailable. It may have been moved or this demo session has ended.');
49
+ const artifact = await response.json();
50
+ content = artifact.content;
51
+ // This policy precedes generated markup and cannot be relaxed by its own meta tags.
52
+ // HTTPS resources support chart libraries; network API calls and nested frames stay disabled.
53
+ const policy = "default-src https: data: blob: 'unsafe-inline' 'unsafe-eval'; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
54
+ document.getElementById('preview').srcdoc =
55
+ `<meta http-equiv="Content-Security-Policy" content="${policy}">` + content;
56
+ download.disabled = false;
57
+ status.textContent = '';
58
+ } catch (error) {
59
+ status.textContent = error instanceof TypeError || error.name === 'TimeoutError'
60
+ ? 'Could not reach your demo. The session may have ended. You can still download the loaded file.'
61
+ : error.message || 'This demo session has ended.';
62
+ } finally { loading = false; refresh.disabled = false; }
63
+ }
64
+ refresh.onclick = load;
65
+ download.onclick = () => {
66
+ if (content === undefined) return;
67
+ const url = URL.createObjectURL(new Blob([content], { type: 'text/html;charset=utf-8' }));
68
+ const link = document.createElement('a');
69
+ link.href = url; link.download = session.path.split('/').pop(); link.click();
70
+ setTimeout(() => URL.revokeObjectURL(url), 10000);
71
+ };
72
+ window.addEventListener('message', (event) => {
73
+ if (session || event.origin !== location.origin || event.source !== window.opener ||
74
+ event.data?.type !== 'compare-artifact-session' || !['left', 'right'].includes(event.data.side) ||
75
+ typeof event.data.path !== 'string' || typeof event.data.token !== 'string') return;
76
+ session = event.data;
77
+ clearInterval(handshake);
78
+ document.getElementById('side').textContent = session.side === 'left' ? 'NATIVE' : 'PENNY';
79
+ document.getElementById('filename').textContent = session.path;
80
+ document.title = `${session.path.split('/').pop()} · ${session.side === 'left' ? 'Native' : 'Penny'}`;
81
+ load();
82
+ });
83
+ const requestSession = () => window.opener?.postMessage({ type: 'compare-artifact-ready' }, location.origin);
84
+ const handshake = setInterval(requestSession, 500);
85
+ requestSession();
86
+ setTimeout(() => {
87
+ clearInterval(handshake);
88
+ if (!session) status.textContent = 'Open this preview from the Files list in an active demo.';
89
+ }, 10000);
90
+ </script>
91
+ </body>
92
+ </html>
@@ -0,0 +1,67 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open, readdir, realpath } from 'node:fs/promises';
3
+ import { extname, join, relative, resolve, sep } from 'node:path';
4
+
5
+ export const maxArtifactBytes = 5 * 1024 * 1024;
6
+ const excluded = new Set(['node_modules', 'vendor']);
7
+ // Keep the type mapping separate so additional preview formats can be added here.
8
+ export const artifactType = (path) => ['.html', '.htm'].includes(extname(path).toLowerCase()) ? 'html' : null;
9
+ const inside = (root, path) => path.startsWith(root + sep);
10
+
11
+ export async function listArtifacts(root) {
12
+ const files = [];
13
+ let inspected = 0;
14
+ async function walk(directory, depth) {
15
+ if (depth > 8 || files.length >= 200 || inspected >= 5000) return;
16
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
17
+ for (const entry of entries) {
18
+ if (++inspected > 5000 || files.length >= 200) break;
19
+ if (entry.name.startsWith('.') || excluded.has(entry.name) || entry.isSymbolicLink()) continue;
20
+ const path = join(directory, entry.name);
21
+ if (entry.isDirectory()) await walk(path, depth + 1);
22
+ else if (entry.isFile() && artifactType(path)) {
23
+ const stat = await lstat(path).catch(() => null);
24
+ if (stat?.isFile() && stat.size <= maxArtifactBytes) {
25
+ files.push({ path: relative(root, path).split(sep).join('/'), type: artifactType(path),
26
+ size: stat.size, modifiedAt: stat.mtimeMs });
27
+ }
28
+ }
29
+ }
30
+ }
31
+ await walk(root, 0);
32
+ return files.sort((a, b) => a.path.localeCompare(b.path));
33
+ }
34
+
35
+ export async function readArtifact(root, path) {
36
+ if (typeof path !== 'string' || !path || path.includes('\\') || path.includes('\0') ||
37
+ path.split('/').some((part) => !part || part === '..' || part.startsWith('.') || excluded.has(part)) ||
38
+ !artifactType(path)) throw new Error('Invalid artifact path');
39
+ const canonicalRoot = await realpath(root);
40
+ const target = resolve(canonicalRoot, path);
41
+ if (!inside(canonicalRoot, target)) throw new Error('Invalid artifact path');
42
+ let current = canonicalRoot;
43
+ for (const part of path.split('/')) {
44
+ current = join(current, part);
45
+ if ((await lstat(current)).isSymbolicLink()) throw new Error('Invalid artifact path');
46
+ }
47
+ const canonicalTarget = await realpath(target);
48
+ if (!inside(canonicalRoot, canonicalTarget)) throw new Error('Invalid artifact path');
49
+ const handle = await open(canonicalTarget, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
50
+ try {
51
+ // On Linux, verify the opened file too, in case a directory was swapped during traversal.
52
+ if (process.platform === 'linux' && !inside(canonicalRoot, await realpath(`/proc/self/fd/${handle.fd}`))) {
53
+ throw new Error('Invalid artifact path');
54
+ }
55
+ const stat = await handle.stat();
56
+ if (!stat.isFile() || stat.size > maxArtifactBytes) throw new Error('Artifact is too large or unavailable');
57
+ const buffer = Buffer.alloc(maxArtifactBytes + 1);
58
+ let length = 0;
59
+ while (length < buffer.length) {
60
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
61
+ if (!bytesRead) break;
62
+ length += bytesRead;
63
+ }
64
+ if (length > maxArtifactBytes) throw new Error('Artifact is too large');
65
+ return buffer.subarray(0, length).toString('utf8');
66
+ } finally { await handle.close(); }
67
+ }
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "@xterm/addon-fit": "^0.10.0",
11
+ "@xterm/addon-unicode11": "^0.8.0",
11
12
  "@xterm/xterm": "^5.5.0",
12
13
  "node-pty": "^1.0.0",
13
14
  "ws": "^8.18.0"
@@ -9,6 +9,7 @@ import { promisify } from 'node:util';
9
9
  import { WebSocketServer } from 'ws';
10
10
  import pty from 'node-pty';
11
11
  import { ClaudeMeter, CodexMeter, saveReport } from './claude-meter.mjs';
12
+ import { listArtifacts, readArtifact } from './artifacts.mjs';
12
13
 
13
14
  const here = dirname(fileURLToPath(import.meta.url));
14
15
  const compareDir = dirname(here);
@@ -31,13 +32,33 @@ if (args.some((arg) => !['--claude', '--compare', '--compare-node', '--harness=c
31
32
  }
32
33
 
33
34
  const files = new Map([
35
+ ['/demo-artifact-preview', ['artifact-preview.html', 'text/html; charset=utf-8']],
36
+ ['/terminal-unicode.js', ['terminal-unicode.js', 'text/javascript; charset=utf-8']],
37
+ ['/addon-unicode11.js', ['node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js', 'text/javascript; charset=utf-8']],
34
38
  ['/', ['terminal-preview.html', 'text/html; charset=utf-8']],
35
39
  ['/xterm.css', ['node_modules/@xterm/xterm/css/xterm.css', 'text/css; charset=utf-8']],
36
40
  ['/xterm.js', ['node_modules/@xterm/xterm/lib/xterm.js', 'text/javascript; charset=utf-8']],
37
41
  ['/addon-fit.js', ['node_modules/@xterm/addon-fit/lib/addon-fit.js', 'text/javascript; charset=utf-8']],
38
42
  ]);
43
+ const artifactSessions = new Map();
39
44
 
40
45
  const server = createServer(async (request, response) => {
46
+ const url = new URL(request.url, 'http://localhost');
47
+ if (['/artifacts', '/artifact'].includes(url.pathname)) {
48
+ const roots = artifactSessions.get(request.headers.authorization?.replace(/^Bearer /, ''));
49
+ const root = roots?.get(url.searchParams.get('side'));
50
+ if (request.method !== 'GET' || !root) { response.writeHead(403).end(); return; }
51
+ try {
52
+ const result = url.pathname === '/artifacts'
53
+ ? { files: await listArtifacts(root) }
54
+ : { content: await readArtifact(root, url.searchParams.get('path')) };
55
+ response.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
56
+ .end(JSON.stringify(result));
57
+ } catch {
58
+ response.writeHead(404).end('Artifact unavailable');
59
+ }
60
+ return;
61
+ }
41
62
  const file = files.get(request.url);
42
63
  if (!file) { response.writeHead(404).end(); return; }
43
64
  try {
@@ -116,6 +137,9 @@ function launchCommand(side, state, sessionId, config) {
116
137
  }
117
138
 
118
139
  wss.on('connection', (ws) => {
140
+ const artifactToken = randomUUID();
141
+ const artifactRoots = new Map();
142
+ artifactSessions.set(artifactToken, artifactRoots);
119
143
  const sessions = new Map();
120
144
  const compareConfig = new Map();
121
145
  const meters = new Map();
@@ -158,6 +182,7 @@ wss.on('connection', (ws) => {
158
182
  const dispose = () => {
159
183
  if (closed) return;
160
184
  closed = true;
185
+ artifactSessions.delete(artifactToken);
161
186
  for (const session of sessions.values()) {
162
187
  try { session.kill(); } catch { /* already exited */ }
163
188
  }
@@ -179,9 +204,13 @@ wss.on('connection', (ws) => {
179
204
  }
180
205
  compareConfig.set('left', left);
181
206
  compareConfig.set('right', right);
207
+ artifactRoots.set('left', left.cwd);
208
+ artifactRoots.set('right', right.cwd);
209
+ send({ type: 'artifact-session', token: artifactToken });
182
210
  send({ type: 'configured' });
183
211
  } catch (error) {
184
212
  compareConfig.clear();
213
+ artifactRoots.clear();
185
214
  send({ type: 'configuration-error', error: error.message });
186
215
  }
187
216
  return;
@@ -242,6 +271,8 @@ wss.on('connection', (ws) => {
242
271
  cwd: config.cwd, env,
243
272
  });
244
273
  sessions.set(message.side, session);
274
+ artifactRoots.set(message.side, config.cwd);
275
+ if (!compare) send({ type: 'artifact-session', token: artifactToken });
245
276
  if (nodeMeter) {
246
277
  const Meter = config.variant.includes('codex') ? CodexMeter : ClaudeMeter;
247
278
  const role = config.variant.startsWith('penny-') ? 'PENNY' : 'NATIVE';
@@ -17,14 +17,24 @@
17
17
  .pane { min-width: 0; min-height: 0; overflow: hidden; background: #16161b; }
18
18
  .pane + .pane { border-left: 1px solid #4d4d52; }
19
19
  .pane.focused { box-shadow: inset 0 0 0 1px #6a6a72; }
20
- .terminal { width: 100%; height: 100%; padding: 4px 7px; }
20
+ .pane { display: flex; flex-direction: column; }
21
+ .pane header { flex: none; padding: 6px 10px; background: #202027; border-bottom: 1px solid #34343c; }
22
+ .files { position: relative; width: fit-content; }
23
+ .files summary { cursor: pointer; color: #9ded83; }
24
+ .file-list { position: absolute; top: 100%; left: 0; z-index: 3; width: min(420px, 45vw);
25
+ max-height: 300px; overflow: auto; background: #202027; border: 1px solid #4d4d52; padding: 8px; }
26
+ .file-list p { color: #92929c; margin: 6px; }
27
+ .file-list button { display: block; width: 100%; text-align: left; overflow-wrap: anywhere;
28
+ padding: 8px; border: 0; background: none; color: #d7d7dd; font: inherit; cursor: pointer; }
29
+ .file-list button:hover { background: #34343c; }
30
+ .terminal { width: 100%; flex: 1; min-height: 0; padding: 4px 7px; }
21
31
  .pane { position: relative; }
22
- .meter { display: none; position: absolute; top: 4px; right: 13px; z-index: 2;
32
+ .meter { display: none; position: absolute; top: 35px; right: 13px; z-index: 2;
23
33
  width: 190px; white-space: pre; color: #9ded83; line-height: 1.15; font-size: 13px;
24
34
  pointer-events: none; padding: 5px 8px; border-radius: 3px;
25
35
  background: rgba(12, 12, 16, .82); }
26
36
  .pane.metered .meter { display: block; }
27
- .delta { display: none; position: absolute; top: 4px; right: 220px; z-index: 2;
37
+ .delta { display: none; position: absolute; top: 35px; right: 220px; z-index: 2;
28
38
  color: #9ded83; white-space: nowrap; pointer-events: none; }
29
39
  .pane.metered .delta { display: block; }
30
40
  .xterm { height: 100%; }
@@ -69,8 +79,8 @@
69
79
  <span id="connection" style="color:#77777e">connecting…</span>
70
80
  </div>
71
81
  <main class="panes">
72
- <section class="pane focused"><div class="meter" id="left-meter"></div><div class="terminal" id="native"></div></section>
73
- <section class="pane"><div class="delta" id="delta"></div><div class="meter" id="right-meter"></div><div class="terminal" id="penny"></div></section>
82
+ <section class="pane focused"><header><details class="files"><summary id="left-files-count">Files · 0</summary><div class="file-list" id="left-files"><p>HTML files will appear here.</p></div></details></header><div class="meter" id="left-meter"></div><div class="terminal" id="native"></div></section>
83
+ <section class="pane"><header><details class="files"><summary id="right-files-count">Files · 0</summary><div class="file-list" id="right-files"><p>HTML files will appear here.</p></div></details></header><div class="delta" id="delta"></div><div class="meter" id="right-meter"></div><div class="terminal" id="penny"></div></section>
74
84
  </main>
75
85
  <section class="setup" id="setup">
76
86
  <form class="setup-card" id="setup-form">
@@ -109,11 +119,13 @@
109
119
  </section>
110
120
  <script src="/xterm.js"></script>
111
121
  <script src="/addon-fit.js"></script>
122
+ <script src="/addon-unicode11.js"></script>
123
+ <script src="/terminal-unicode.js"></script>
112
124
  <script>
113
125
  const options = {
114
126
  fontFamily: 'SFMono-Regular, Menlo, Consolas, monospace', fontSize: 13,
115
127
  lineHeight: 1.15, cursorBlink: true, cursorStyle: 'block',
116
- allowTransparency: false, scrollback: 1000,
128
+ allowTransparency: false, scrollback: 1000, allowProposedApi: true,
117
129
  theme: { background: '#16161b', foreground: '#d7d7dd', cursor: '#d7d7dd',
118
130
  selectionBackground: '#4b5369', black: '#24242a', red: '#e88b8b',
119
131
  green: '#9ded83', yellow: '#e6c583', blue: '#8eaeeb', magenta: '#c8a7e8',
@@ -121,6 +133,8 @@
121
133
  };
122
134
  const native = new Terminal(options);
123
135
  const penny = new Terminal(options);
136
+ configureCompareUnicode(native, Unicode11Addon.Unicode11Addon);
137
+ configureCompareUnicode(penny, Unicode11Addon.Unicode11Addon);
124
138
  const fit = { left: new FitAddon.FitAddon(), right: new FitAddon.FitAddon() };
125
139
  native.loadAddon(fit.left);
126
140
  penny.loadAddon(fit.right);
@@ -131,6 +145,58 @@
131
145
  const panes = document.querySelectorAll('.pane');
132
146
  const send = message => { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); };
133
147
  let socket;
148
+ let sessionToken;
149
+ let fileTimer;
150
+ let pollingFiles = false;
151
+ const previewTabs = new Map();
152
+ async function pollFiles() {
153
+ if (pollingFiles || !sessionToken) return;
154
+ pollingFiles = true;
155
+ try {
156
+ await Promise.all(['left', 'right'].map(async side => {
157
+ const list = document.getElementById(`${side}-files`);
158
+ try {
159
+ const response = await fetch(`/artifacts?side=${side}`, {
160
+ headers: { Authorization: `Bearer ${sessionToken}` }, cache: 'no-store',
161
+ signal: AbortSignal.timeout(10000),
162
+ });
163
+ if (!response.ok) throw new Error('Files unavailable');
164
+ const { files } = await response.json();
165
+ document.getElementById(`${side}-files-count`).textContent = `Files · ${files.length}`;
166
+ const paths = JSON.stringify(files.map(file => file.path));
167
+ if (list.dataset.paths === paths) return;
168
+ list.dataset.paths = paths;
169
+ list.replaceChildren();
170
+ if (!files.length) {
171
+ const hint = document.createElement('p'); hint.textContent = 'HTML files will appear here.';
172
+ list.append(hint);
173
+ }
174
+ for (const file of files) {
175
+ const button = document.createElement('button'); button.textContent = file.path;
176
+ button.title = 'Open preview in a new tab';
177
+ button.onclick = () => {
178
+ for (const tab of previewTabs.keys()) if (tab.closed) previewTabs.delete(tab);
179
+ const tab = window.open('/demo-artifact-preview', '_blank');
180
+ if (tab) previewTabs.set(tab, { side, path: file.path });
181
+ else document.getElementById('connection').textContent = 'Allow pop-ups to open file previews.';
182
+ };
183
+ list.append(button);
184
+ }
185
+ } catch {
186
+ delete list.dataset.paths;
187
+ list.replaceChildren();
188
+ const hint = document.createElement('p'); hint.textContent = 'Files unavailable. Retrying…';
189
+ list.append(hint);
190
+ }
191
+ }));
192
+ } finally { pollingFiles = false; }
193
+ }
194
+ window.addEventListener('message', event => {
195
+ if (event.origin !== location.origin || event.data?.type !== 'compare-artifact-ready') return;
196
+ const file = previewTabs.get(event.source);
197
+ if (file && sessionToken) event.source.postMessage(
198
+ { type: 'compare-artifact-session', ...file, token: sessionToken }, location.origin);
199
+ });
134
200
  const setFocus = index => { panes.forEach((p, i) => p.classList.toggle('focused', i === index)); (index ? penny : native).focus(); };
135
201
  const setSync = value => {
136
202
  sync = value;
@@ -166,6 +232,12 @@
166
232
  socket.addEventListener('message', event => {
167
233
  const message = JSON.parse(event.data);
168
234
  const terminal = message.side === 'left' ? native : penny;
235
+ if (message.type === 'artifact-session') {
236
+ sessionToken = message.token;
237
+ clearInterval(fileTimer);
238
+ void pollFiles();
239
+ fileTimer = setInterval(pollFiles, 3000);
240
+ }
169
241
  if (message.type === 'configured' && pendingConfig) {
170
242
  for (const [side, terminal] of [['left', native], ['right', penny]])
171
243
  send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
@@ -232,6 +304,7 @@
232
304
  }
233
305
  });
234
306
  socket.addEventListener('close', () => {
307
+ clearInterval(fileTimer);
235
308
  if (!document.getElementById('exit').disabled) {
236
309
  document.getElementById('connection').textContent = 'disconnected — reload to reconnect';
237
310
  }
@@ -295,8 +368,8 @@
295
368
  if (event.code === 'KeyS') { event.preventDefault(); event.stopPropagation(); setSync(!sync); }
296
369
  if (event.code === 'KeyX') { event.preventDefault(); event.stopPropagation(); exit(); }
297
370
  }, true);
298
- panes[0].addEventListener('pointerdown', () => setFocus(0));
299
- panes[1].addEventListener('pointerdown', () => setFocus(1));
371
+ document.getElementById('native').addEventListener('pointerdown', () => setFocus(0));
372
+ document.getElementById('penny').addEventListener('pointerdown', () => setFocus(1));
300
373
  native.onData(data => {
301
374
  send({ type: 'input', side: 'left', data });
302
375
  if (sync) send({ type: 'input', side: 'right', data });