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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.39",
3
+ "version": "0.3.41",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
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";
@@ -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 });