pennyrouter 0.3.39 → 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
package/src/cli.js
CHANGED
|
@@ -2035,8 +2035,6 @@ async function uninstall(flags) {
|
|
|
2035
2035
|
|
|
2036
2036
|
if (anthropicTokenNotice) {
|
|
2037
2037
|
console.log("");
|
|
2038
|
-
console.log("Note: Your stored Anthropic token remains on the server for other machines.");
|
|
2039
|
-
console.log("To delete it from the server, run: pennyrouter auth anthropic --forget");
|
|
2040
2038
|
}
|
|
2041
2039
|
|
|
2042
2040
|
manifest.installations = kept;
|
package/src/launch.js
CHANGED
|
@@ -320,6 +320,16 @@ export async function installPennyCompare() {
|
|
|
320
320
|
await mkdir(dirname(target), { recursive: true });
|
|
321
321
|
await cp(await findInstalledPackage(name), target, { recursive: true });
|
|
322
322
|
}
|
|
323
|
+
if (process.platform === "darwin") {
|
|
324
|
+
// node-pty 1.1.0's macOS prebuild ships a non-executable spawn-helper, and the copy
|
|
325
|
+
// keeps that mode; without +x every PTY launch fails at posix_spawnp.
|
|
326
|
+
const helper = join(modules, "node-pty", "prebuilds", `darwin-${process.arch}`, "spawn-helper");
|
|
327
|
+
try {
|
|
328
|
+
await chmod(helper, (await stat(helper)).mode | 0o111);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (error.code !== "ENOENT") throw error;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
323
333
|
if (process.platform === "win32") {
|
|
324
334
|
const link = join(pennyBinDir(), "penny-compare.cmd");
|
|
325
335
|
const marker = "@REM PennyRouter-managed compare launcher";
|
|
@@ -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 });
|