pennyrouter 0.3.40 → 0.3.41
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
|
@@ -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
|
-
.
|
|
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:
|
|
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:
|
|
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
|
-
|
|
299
|
-
|
|
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 });
|