pennyrouter 0.3.36 → 0.3.38

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/README.md CHANGED
@@ -87,12 +87,12 @@ of `npx pennyrouter install`; to install or refresh it separately, run:
87
87
  npx pennyrouter compare
88
88
  ```
89
89
 
90
- On macOS/Linux, the installer puts `penny-compare` and `penny-session` on PATH alongside `penny`.
91
- These commands require `tmux`, Python 3.10+, and the harnesses being compared. On Windows,
92
- `penny-compare` starts a local browser terminal with native Claude and `penny claude` side by
93
- side. Its live token and cost readouts use Claude transcripts, with no tmux or Python install.
94
- Open the local URL printed by the command. Sync is Ctrl+Shift+S; Exit and save report is
95
- Ctrl+Shift+X. The Windows path currently supports Claude Code comparisons.
90
+ Run `penny compare` on macOS, Linux, or Windows to open the browser-based setup. It mirrors the
91
+ classic launcher's Claude/Codex pane, directory, label, and model choices. The browser version
92
+ uses Python session metering on macOS/Linux and Node transcript metering on Windows, so it does
93
+ not need tmux. The classic terminal interface remains available as `penny-compare` on macOS/Linux
94
+ and requires tmux and Python 3.10+. Sync is Ctrl+Shift+S; Exit saves the report and shows the
95
+ final summary.
96
96
 
97
97
  On macOS and Linux, a normal install adds a small `penny` launcher under `~/.local/bin`.
98
98
  On Windows it installs `penny.cmd` under `%LOCALAPPDATA%\\PennyRouter\\bin` and adds that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.36",
3
+ "version": "0.3.38",
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
@@ -138,9 +138,8 @@ export async function main(argv = process.argv.slice(2)) {
138
138
  throw new Error("Penny Compare requires macOS, Linux, or Windows.");
139
139
  }
140
140
  console.log(`Penny Compare installed at ${result.path}`);
141
- console.log(process.platform === "win32"
142
- ? "Run `penny-compare`, then open the local URL it prints."
143
- : "Run `penny-compare` or `penny-session` to use it.");
141
+ console.log("Run `penny compare` to open the browser-based comparison setup.");
142
+ if (process.platform !== "win32") console.log("Run `penny-compare` for the classic tmux interface.");
144
143
  return;
145
144
  }
146
145
 
@@ -1010,9 +1009,9 @@ export async function install(flags = {}) {
1010
1009
  }
1011
1010
  }
1012
1011
  if (runtimeResult && !runtimeResult.available) {
1013
- console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
1012
+ console.log(color(color("Add the PennyRouter launcher directory to PATH before using the `penny` command.", "bold"), "yellow"));
1014
1013
  } else if (runtimeResult?.changed) {
1015
- console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
1014
+ console.log(color(color("Open a new terminal (or reload your shell) before using the `penny` command.", "bold"), "yellow"));
1016
1015
  }
1017
1016
 
1018
1017
  // Registered after routing, which is the primary job: a naming conflict here reports itself
@@ -1460,9 +1459,9 @@ async function update(flags) {
1460
1459
 
1461
1460
  console.log("");
1462
1461
  if (runtimeResult && !runtimeResult.available) {
1463
- console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
1462
+ console.log(color(color("Add the PennyRouter launcher directory to PATH before using the `penny` command.", "bold"), "yellow"));
1464
1463
  } else if (runtimeResult?.changed) {
1465
- console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
1464
+ console.log(color(color("Open a new terminal (or reload your shell) before using the `penny` command.", "bold"), "yellow"));
1466
1465
  }
1467
1466
  console.log("Restart any running Claude Code or Codex sessions to pick up the update.");
1468
1467
  reportFailures(failures);
@@ -2391,6 +2390,7 @@ function color(text, style) {
2391
2390
  bold: ["\x1b[1m", "\x1b[22m"],
2392
2391
  cyan: ["\x1b[36m", "\x1b[39m"],
2393
2392
  green: ["\x1b[32m", "\x1b[39m"],
2393
+ yellow: ["\x1b[33m", "\x1b[39m"],
2394
2394
  };
2395
2395
  const pair = codes[style];
2396
2396
  return pair ? `${pair[0]}${text}${pair[1]}` : text;
package/src/launch.js CHANGED
@@ -274,6 +274,22 @@ async function copyRuntime() {
274
274
  await cp(join(source, "package.json"), join(destination, "package.json"), { force: true });
275
275
  }
276
276
 
277
+ // npx and local installs hoist dependencies above the package, so walk up like Node's resolver.
278
+ async function findInstalledPackage(name) {
279
+ let dir = packageRoot();
280
+ for (;;) {
281
+ const candidate = join(dir, "node_modules", name);
282
+ try {
283
+ if ((await stat(candidate)).isDirectory()) return candidate;
284
+ } catch (error) {
285
+ if (error.code !== "ENOENT") throw error;
286
+ }
287
+ const parent = dirname(dir);
288
+ if (parent === dir) throw new Error(`Could not find installed dependency ${name} from ${packageRoot()}`);
289
+ dir = parent;
290
+ }
291
+ }
292
+
277
293
  /** Install the bundled, repository-independent Penny Compare tools. */
278
294
  export async function installPennyCompare() {
279
295
  if (!(process.platform === "darwin" || process.platform === "linux" || process.platform === "win32")) {
@@ -298,13 +314,13 @@ export async function installPennyCompare() {
298
314
  }
299
315
  await mkdir(join(destination, "reports"), { recursive: true, mode: 0o700 });
300
316
  await mkdir(pennyBinDir(), { recursive: true, mode: 0o755 });
317
+ const modules = join(destination, "web-demo", "node_modules");
318
+ for (const name of ["@xterm/addon-fit", "@xterm/xterm", "node-pty", "ws"]) {
319
+ const target = join(modules, name);
320
+ await mkdir(dirname(target), { recursive: true });
321
+ await cp(await findInstalledPackage(name), target, { recursive: true });
322
+ }
301
323
  if (process.platform === "win32") {
302
- const modules = join(destination, "web-demo", "node_modules");
303
- for (const name of ["@xterm/addon-fit", "@xterm/xterm", "node-pty", "ws"]) {
304
- const target = join(modules, name);
305
- await mkdir(dirname(target), { recursive: true });
306
- await cp(join(packageRoot(), "node_modules", name), target, { recursive: true });
307
- }
308
324
  const link = join(pennyBinDir(), "penny-compare.cmd");
309
325
  const marker = "@REM PennyRouter-managed compare launcher";
310
326
  let previous = "";
@@ -1485,16 +1501,36 @@ export async function runPennyClaudeStats(args, profile) {
1485
1501
  return result.code ?? 1;
1486
1502
  }
1487
1503
 
1504
+ /** Launch the separately installed Penny Compare app from the `penny` command. */
1505
+ async function runPennyCompare(args) {
1506
+ const compareDir = pennyCompareDir();
1507
+ const executable = join(compareDir, "web-demo", "server.mjs");
1508
+ try {
1509
+ await stat(executable);
1510
+ } catch {
1511
+ throw new Error("Penny Compare is not installed. Run `npx pennyrouter compare` first.");
1512
+ }
1513
+ const child = spawn(process.execPath, [executable, "--compare", ...args], {
1514
+ cwd: process.cwd(), env: process.env, stdio: "inherit",
1515
+ });
1516
+ const result = await waitForChild(child);
1517
+ if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
1518
+ return result.code ?? 1;
1519
+ }
1520
+
1488
1521
  export async function runPenny(argv = process.argv.slice(2)) {
1489
1522
  const harness = argv[0];
1490
1523
  if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
1491
- console.log("Usage: penny <claude|codex|opencode> [--penny-profile <name>] [native arguments...]");
1524
+ console.log("Usage: penny <claude|codex|opencode|compare> [arguments...]");
1525
+ console.log(" penny compare launches Penny Compare; install it with `npx pennyrouter compare`.");
1526
+ console.log(" penny <claude|codex|opencode> [--penny-profile <name>] [native arguments...]");
1492
1527
  console.log(" --private / --cloud select the like-named profile.");
1493
1528
  console.log(" penny claude --stats adds the live single-session token readout.");
1494
1529
  return 0;
1495
1530
  }
1531
+ if (harness === "compare") return runPennyCompare(argv.slice(1));
1496
1532
  if (!["claude", "codex", "opencode"].includes(harness)) {
1497
- throw new Error(`Unknown harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
1533
+ throw new Error(`Unknown Penny command "${harness}". Try penny claude, penny codex, penny opencode, or penny compare.`);
1498
1534
  }
1499
1535
  const statsSelection = extractStatsArgs(argv.slice(1));
1500
1536
  if (statsSelection.stats && harness !== "claude") {
@@ -62,9 +62,11 @@ label_right=""
62
62
  wire=""
63
63
  wire_both=""
64
64
  wire_https=""
65
+ pricing_profile=""
65
66
  while [ $# -gt 0 ]; do
66
67
  case "$1" in
67
68
  --model) model="$2"; shift 2 ;;
69
+ --pricing-profile) pricing_profile="$2"; shift 2 ;;
68
70
  --dir-left) dir_left="$2"; shift 2 ;;
69
71
  --dir-right) dir_right="$2"; shift 2 ;;
70
72
  --native-proxy) native_proxy="--native-proxy"; shift ;;
@@ -175,6 +177,17 @@ else
175
177
  fi
176
178
  fi
177
179
 
180
+ # Pricing is an estimate-only display setting, so choose it explicitly instead of silently
181
+ # coupling it to the native transport proxy. This also keeps direct (two-argument) launches
182
+ # consistent with menu launches.
183
+ if [ -z "$pricing_profile" ]; then
184
+ pricing_profile="$(choose 'pricing profile' standard standard govcloud)"
185
+ fi
186
+ case "$pricing_profile" in
187
+ standard|govcloud) ;;
188
+ *) echo "unknown pricing profile: $pricing_profile (try standard or govcloud)" >&2; exit 1 ;;
189
+ esac
190
+
178
191
  # A directory that does not exist would otherwise surface as a confusing tmux failure.
179
192
  for candidate in "$dir_left" "$dir_right"; do
180
193
  if [ -n "$candidate" ] && [ ! -d "${candidate/#\~/$HOME}" ]; then
@@ -243,9 +256,7 @@ workdir_right_arg=" --cwd $(printf '%q' "$workdir_right")"
243
256
  # a penny-* pane already owns its own proxy routing and ignores the flag either way.
244
257
  native_proxy_left=""
245
258
  native_proxy_right=""
246
- pricing_profile="standard"
247
259
  if [ -n "$native_proxy" ]; then
248
- pricing_profile="govcloud"
249
260
  case "$left" in claude|codex) native_proxy_left="$native_proxy" ;; esac
250
261
  case "$right" in claude|codex) native_proxy_right="$native_proxy" ;; esac
251
262
  fi
@@ -15,6 +15,9 @@ RATES = {
15
15
  # OpenAI does not offer a 1-hour cache tier; its write rate applies uniformly.
16
16
  "gpt-5-codex": (1.25e-06, 1.25e-07, 1.25e-06, 1.25e-06, 1.00e-05),
17
17
  "gpt-5.1-codex": (1.25e-06, 1.25e-07, 1.25e-06, 1.25e-06, 1.00e-05),
18
+ # GPT-5.6 standard pricing. Keep Luna explicit so penny-session does not silently
19
+ # price it as Claude Sonnet when no GovCloud profile was selected.
20
+ "gpt-5.6-luna": (0.20e-06, 0.020e-06, 0.250e-06, 0.250e-06, 1.20e-06),
18
21
  }
19
22
 
20
23
  # --native-proxy means the comparison is measuring the GovCloud route. Pricing remains the
@@ -21,10 +21,10 @@ To run the current Penny Compare measurement code in the panes:
21
21
  npm start -- --compare
22
22
  ```
23
23
 
24
- This starts native Claude on the left and `penny claude` on the right. On macOS/Linux, it uses the existing Python session reader, cost model, HUD, and delta calculation. On Windows, it reads Claude transcripts and prices calls in Node, with no Python or tmux requirement. Exit saves a JSON report under `apps/compare/reports` and prints the summary in the left pane. Both platforms need Claude Code and the `penny` command on `PATH`.
24
+ This opens a setup screen with the same Claude/Codex pane choices, directories, labels, and shared model selection as the classic launcher. The default is native Claude on the left and Penny Claude on the right, using `<current directory>/native` and `<current directory>/penny`. On macOS/Linux, it uses the existing Python session reader, cost model, HUD, and delta calculation. On Windows, it reads Claude or Codex transcripts and prices calls in Node, with no Python or tmux requirement. Exit saves a JSON report under `apps/compare/reports` and shows the final summary. The selected harness commands must be on `PATH`.
25
25
 
26
- The Windows path currently supports Claude Code comparisons. Codex and OpenCode transcript readers are still in the Python path.
26
+ Use `--harness=codex` to preselect Codex in the setup screen. The pane selectors can also mix Claude and Codex; the shared model control is available when both panes use the same harness family.
27
27
 
28
- To exercise the Windows measurement path on macOS/Linux, use `npm start -- --compare-node`.
28
+ To exercise the Node measurement paths on macOS/Linux, use `npm start -- --compare-node --harness=claude` for Claude or `npm start -- --compare-node --harness=codex` for Codex. Claude is the default if you omit `--harness`.
29
29
 
30
30
  The demo requires Claude Code on `PATH` for that option. Closing or reloading the page ends both sessions. The local server listens only on `127.0.0.1`; it does not start a PennyRouter gateway.
@@ -21,11 +21,13 @@ function emptyTotals() {
21
21
  }
22
22
 
23
23
  export class ClaudeMeter {
24
- constructor({ sessionId, cwd, side, role }) {
24
+ constructor({ sessionId, cwd, side, role, label, variant }) {
25
25
  this.sessionId = sessionId;
26
26
  this.cwd = cwd;
27
27
  this.side = side;
28
28
  this.role = role;
29
+ this.label = label || role;
30
+ this.variant = variant;
29
31
  this.root = join(homedir(), '.claude', 'projects');
30
32
  this.files = new Map();
31
33
  this.ids = new Set();
@@ -146,16 +148,115 @@ export class ClaudeMeter {
146
148
  }
147
149
 
148
150
  snapshot() {
149
- return { side: this.side, role: this.role, label: this.role, variant: this.side === 'left' ? 'claude' : 'penny-claude',
151
+ return { side: this.side, role: this.role, label: this.label, variant: this.variant,
150
152
  harness: 'claude-code', cwd: this.cwd, pricing_profile: 'standard', session_id: this.sessionId,
151
153
  transcript_path: this.transcriptPath, model: this.model, call_count: this.calls.length,
152
154
  totals: { ...this.totals } };
153
155
  }
154
156
  }
155
157
 
158
+ // Codex writes cumulative token_count snapshots to dated rollout JSONL files.
159
+ // Keep a cursor per transcript and charge each snapshot's delta once.
160
+ export class CodexMeter extends ClaudeMeter {
161
+ constructor(options) {
162
+ super(options);
163
+ this.root = join(homedir(), '.codex', 'sessions');
164
+ this.transcriptPath = null;
165
+ this.previous = null;
166
+ this.started = Date.now() / 1000;
167
+ }
168
+
169
+ async findTranscript() {
170
+ if (this.transcriptPath) return this.transcriptPath;
171
+ const visit = async (directory) => {
172
+ let entries;
173
+ try { entries = await readdir(directory, { withFileTypes: true }); } catch { return []; }
174
+ const found = [];
175
+ for (const entry of entries) {
176
+ const path = join(directory, entry.name);
177
+ if (entry.isDirectory()) found.push(...await visit(path));
178
+ else if (entry.name.endsWith('.jsonl')) {
179
+ try {
180
+ const handle = await open(path, 'r');
181
+ const buffer = Buffer.alloc(4096);
182
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
183
+ await handle.close();
184
+ const meta = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8').split('\n', 1)[0]).payload;
185
+ const info = await stat(path);
186
+ const expectedProvider = this.role === 'NATIVE' ? 'openai' : 'pennyrouter';
187
+ if (meta?.cwd && meta.cwd.toLowerCase() === this.cwd.toLowerCase() &&
188
+ meta.model_provider === expectedProvider &&
189
+ info.mtimeMs / 1000 >= this.started - 3) found.push([path, info.mtimeMs]);
190
+ } catch { /* incomplete or unrelated rollout */ }
191
+ }
192
+ }
193
+ return found;
194
+ };
195
+ const candidates = await visit(this.root);
196
+ candidates.sort((a, b) => b[1] - a[1]);
197
+ this.transcriptPath = candidates[0]?.[0] || null;
198
+ return this.transcriptPath;
199
+ }
200
+
201
+ async poll() {
202
+ const path = await this.findTranscript();
203
+ if (!path) return this.snapshot();
204
+ let size;
205
+ try { ({ size } = await stat(path)); } catch { return this.snapshot(); }
206
+ let offset = this.files.get(path) || 0;
207
+ if (size < offset) { offset = 0; this.previous = null; }
208
+ if (size === offset) return this.snapshot();
209
+ const handle = await open(path, 'r');
210
+ try {
211
+ const buffer = Buffer.alloc(size - offset);
212
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
213
+ const text = buffer.subarray(0, bytesRead).toString('utf8');
214
+ const end = text.lastIndexOf('\n');
215
+ if (end < 0) return this.snapshot();
216
+ this.files.set(path, offset + Buffer.byteLength(text.slice(0, end + 1)));
217
+ for (const line of text.slice(0, end).split('\n')) {
218
+ let obj;
219
+ try { obj = JSON.parse(line); } catch { continue; }
220
+ const payload = obj.payload;
221
+ if (obj.type === 'turn_context' && payload?.model) this.model = payload.model;
222
+ if (payload?.type !== 'token_count' || !payload.info?.total_token_usage) continue;
223
+ const total = payload.info.total_token_usage;
224
+ const fingerprint = ['input_tokens', 'cached_input_tokens', 'cache_write_input_tokens',
225
+ 'output_tokens', 'reasoning_output_tokens'].map((key) => Number(total[key]) || 0);
226
+ if (this.previous && fingerprint.every((value, index) => value === this.previous[index])) continue;
227
+ const delta = fingerprint.map((value, index) => Math.max(0, value - (this.previous?.[index] || 0)));
228
+ this.previous = fingerprint;
229
+ const [input, cached, write, output, reasoning] = delta;
230
+ const fresh = Math.max(0, input - cached - write);
231
+ const prices = [1.25e-6, .125e-6, 1.25e-6, 1.25e-6, 10e-6];
232
+ const cost = fresh * prices[0] + cached * prices[1] + write * prices[2] +
233
+ (output - reasoning) * prices[4] + reasoning * prices[4];
234
+ const call = { timestamp: obj.timestamp || null, recorded_at: Date.now() / 1000,
235
+ model: this.model, cache_read: cached, cache_write: write, write_5m: write,
236
+ write_1h: 0, fresh_input: fresh, output, reasoning_output: reasoning,
237
+ total: input + output, cost, context_tokens: input, pricing_profile: 'standard',
238
+ rates_per_million: { input: 1.25, cache_read: .125, cache_write_5m: 1.25,
239
+ cache_write_1h: 1.25, output: 10 } };
240
+ this.calls.push(call);
241
+ this.totals.cache_read += cached; this.totals.cache_write += write;
242
+ this.totals.write_5m += write; this.totals.fresh_input += fresh;
243
+ this.totals.output += output; this.totals.reasoning_output += reasoning;
244
+ this.totals.total += call.total; this.totals.cost += cost;
245
+ }
246
+ } finally { await handle.close(); }
247
+ return this.snapshot();
248
+ }
249
+
250
+ snapshot() {
251
+ return { ...super.snapshot(), variant: this.side === 'left' ? 'codex' : 'penny-codex',
252
+ harness: 'codex', transcript_path: this.transcriptPath };
253
+ }
254
+ }
255
+
156
256
  export async function saveReport(left, right, reportsDir) {
157
- const native = left.snapshot();
158
- const penny = right.snapshot();
257
+ const sides = [left.snapshot(), right.snapshot()];
258
+ const native = sides.find((side) => side.role === 'NATIVE') || sides[0];
259
+ const penny = sides.find((side) => side.role === 'PENNY') || sides[1];
159
260
  const savings = {
160
261
  cost: native.totals.cost - penny.totals.cost,
161
262
  tokens: native.totals.total - penny.totals.total,
@@ -1,29 +1,32 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
4
4
  import { spawn, execFile } from 'node:child_process';
5
- import { join, dirname } from 'node:path';
6
- import { tmpdir } from 'node:os';
5
+ import { join, dirname, resolve } from 'node:path';
6
+ import { homedir, tmpdir } from 'node:os';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { promisify } from 'node:util';
9
9
  import { WebSocketServer } from 'ws';
10
10
  import pty from 'node-pty';
11
- import { ClaudeMeter, saveReport } from './claude-meter.mjs';
11
+ import { ClaudeMeter, CodexMeter, saveReport } from './claude-meter.mjs';
12
12
 
13
13
  const here = dirname(fileURLToPath(import.meta.url));
14
14
  const compareDir = dirname(here);
15
15
  const execFileAsync = promisify(execFile);
16
16
  const args = process.argv.slice(2);
17
17
  const claude = args.includes('--claude');
18
+ const harnessArg = args.find((arg) => /^--harness=(claude|codex)$/.test(arg));
19
+ const startupHarness = harnessArg?.slice('--harness='.length) || 'claude';
18
20
  const nodeMeter = args.includes('--compare-node') ||
19
21
  (args.includes('--compare') && process.platform === 'win32');
20
22
  const compare = args.includes('--compare') || nodeMeter;
21
23
  const pythonCompare = compare && !nodeMeter && process.platform !== 'win32';
22
24
  const portArg = args.find((arg) => /^--port=\d+$/.test(arg));
23
25
  const port = Number(portArg?.slice(7) ?? 8789);
24
- if (args.some((arg) => !['--claude', '--compare', '--compare-node', portArg].includes(arg)) ||
25
- (claude && compare) || port < 1 || port > 65535) {
26
- console.error('Usage: npm start -- [--claude | --compare | --compare-node] [--port=8789]');
26
+ if (args.some((arg) => !['--claude', '--compare', '--compare-node', '--harness=claude', '--harness=codex', portArg].includes(arg)) ||
27
+ (claude && compare) || (harnessArg && !compare) ||
28
+ (args.includes('--harness=claude') && args.includes('--harness=codex')) || port < 1 || port > 65535) {
29
+ console.error('Usage: npm start -- [--claude | --compare [--harness=claude|codex] | --compare-node [--harness=claude|codex]] [--port=8789]');
27
30
  process.exit(1);
28
31
  }
29
32
 
@@ -38,7 +41,13 @@ const server = createServer(async (request, response) => {
38
41
  const file = files.get(request.url);
39
42
  if (!file) { response.writeHead(404).end(); return; }
40
43
  try {
41
- const body = await readFile(join(here, file[0]));
44
+ let body = await readFile(join(here, file[0]));
45
+ if (request.url === '/') {
46
+ body = body.toString('utf8')
47
+ .replace('__PENNY_CWD__', JSON.stringify(process.cwd()).replaceAll('<', '\\u003c'))
48
+ .replace('__PENNY_COMPARE__', JSON.stringify(compare))
49
+ .replace('__PENNY_DEFAULT_HARNESS__', JSON.stringify(startupHarness));
50
+ }
42
51
  response.writeHead(200, { 'Content-Type': file[1], 'Cache-Control': 'no-store' }).end(body);
43
52
  } catch (error) {
44
53
  console.error(error);
@@ -57,22 +66,46 @@ server.on('upgrade', (request, socket, head) => {
57
66
  wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws));
58
67
  });
59
68
 
60
- function launchCommand(side, state, sessionId) {
69
+ const variants = new Set(['claude', 'penny-claude', 'codex', 'penny-codex']);
70
+ const safeModel = /^[A-Za-z0-9._/-]{1,160}$/;
71
+
72
+ async function validateConfig(config) {
73
+ const codexModels = ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini'];
74
+ const claudeModels = ['opus', 'sonnet', 'haiku', 'fable'];
75
+ if (!config || !variants.has(config.variant) || typeof config.cwd !== 'string' ||
76
+ config.cwd.length > 4096 || typeof config.label !== 'string' || config.label.length > 80 ||
77
+ typeof config.model !== 'string' || (config.model && !safeModel.test(config.model)) ||
78
+ (config.model && config.variant.includes('codex') && !codexModels.includes(config.model)) ||
79
+ (config.model && !config.variant.includes('codex') &&
80
+ !claudeModels.includes(config.model) && !config.model.startsWith('claude-'))) {
81
+ throw new Error('Invalid compare settings. Choose Claude or Codex, a directory, and a valid model name.');
82
+ }
83
+ const expanded = config.cwd.startsWith('~/') ? join(homedir(), config.cwd.slice(2)) : config.cwd;
84
+ const cwd = resolve(expanded);
85
+ if (!(await stat(cwd)).isDirectory()) throw new Error(`Directory does not exist: ${cwd}`);
86
+ return { ...config, cwd };
87
+ }
88
+
89
+ function launchCommand(side, state, sessionId, config) {
61
90
  if (pythonCompare) {
62
- const variant = side === 'left' ? 'claude' : 'penny-claude';
63
- const args = [join(compareDir, 'pane.py'), variant, '--side', side,
64
- '--cwd', process.cwd(), '--pricing-profile', 'standard'];
91
+ const args = [join(compareDir, 'pane.py'), config.variant, '--side', side,
92
+ '--cwd', config.cwd, '--pricing-profile', 'standard'];
93
+ if (config.model) args.push('--model', config.model);
94
+ if (config.label) args.push('--label', config.label);
65
95
  if (side === 'right') args.push('--delta-file', join(state, 'delta.txt'));
66
96
  return ['python3', args];
67
97
  }
68
98
  if (nodeMeter) {
99
+ const codex = config.variant.includes('codex');
100
+ const command = config.variant.startsWith('penny-')
101
+ ? `penny ${codex ? 'codex' : 'claude'}` : (codex ? 'codex' : 'claude');
102
+ const modelArg = config.model ? ` --model ${config.model}` : '';
69
103
  if (process.platform === 'win32') {
70
104
  const shell = process.env.ComSpec || 'cmd.exe';
71
- return [shell, ['/d', '/s', '/c', side === 'left'
72
- ? `claude --session-id ${sessionId}` : `penny claude --session-id ${sessionId}`]];
105
+ return [shell, ['/d', '/s', '/c', `${command}${codex ? '' : ` --session-id ${sessionId}`}${modelArg}`]];
73
106
  }
74
107
  const shell = process.env.SHELL || '/bin/sh';
75
- return [shell, ['-lc', `exec ${side === 'left' ? 'claude' : 'penny claude'} --session-id ${sessionId}`]];
108
+ return [shell, ['-lc', `exec ${command}${codex ? '' : ` --session-id ${sessionId}`}${modelArg}`]];
76
109
  }
77
110
  if (process.platform === 'win32') {
78
111
  const shell = process.env.ComSpec || 'cmd.exe';
@@ -84,6 +117,7 @@ function launchCommand(side, state, sessionId) {
84
117
 
85
118
  wss.on('connection', (ws) => {
86
119
  const sessions = new Map();
120
+ const compareConfig = new Map();
87
121
  const meters = new Map();
88
122
  const snapshots = new Map();
89
123
  let state;
@@ -135,6 +169,23 @@ wss.on('connection', (ws) => {
135
169
  const handle = async (raw) => {
136
170
  let message;
137
171
  try { message = JSON.parse(raw); } catch { return; }
172
+ if (message.type === 'configure') {
173
+ try {
174
+ if (!compare) throw new Error('Compare setup is unavailable in shell demo mode.');
175
+ const left = await validateConfig(message.config?.left);
176
+ const right = await validateConfig(message.config?.right);
177
+ if (left.model && left.variant.includes('codex') !== right.variant.includes('codex')) {
178
+ throw new Error('Choose the same model family for both panes, or leave Model set to default.');
179
+ }
180
+ compareConfig.set('left', left);
181
+ compareConfig.set('right', right);
182
+ send({ type: 'configured' });
183
+ } catch (error) {
184
+ compareConfig.clear();
185
+ send({ type: 'configuration-error', error: error.message });
186
+ }
187
+ return;
188
+ }
138
189
  if (message.type === 'exit') {
139
190
  if (exiting) return;
140
191
  exiting = true;
@@ -166,6 +217,10 @@ wss.on('connection', (ws) => {
166
217
  if (message.type === 'start' && !sessions.has(message.side) &&
167
218
  size(message.cols, 500) && size(message.rows, 200)) {
168
219
  try {
220
+ const config = compare
221
+ ? compareConfig.get(message.side)
222
+ : { variant: 'claude', cwd: process.cwd(), label: '', model: '' };
223
+ if (!config) throw new Error('Choose and validate both compare panes before starting.');
169
224
  await ready;
170
225
  if (closed) return;
171
226
  if (pythonCompare && !delta) {
@@ -173,8 +228,8 @@ wss.on('connection', (ws) => {
173
228
  { stdio: 'ignore' });
174
229
  delta.on('error', (error) => send({ type: 'error', side: 'right', error: error.message }));
175
230
  }
176
- const sessionId = nodeMeter ? randomUUID() : undefined;
177
- const [command, commandArgs] = launchCommand(message.side, state, sessionId);
231
+ const sessionId = nodeMeter && !config.variant.includes('codex') ? randomUUID() : undefined;
232
+ const [command, commandArgs] = launchCommand(message.side, state, sessionId, config);
178
233
  const env = { ...process.env, TERM: 'xterm-256color',
179
234
  ...(pythonCompare ? { PENNY_COMPARE_STATE: state } : {}) };
180
235
  if (compare && message.side === 'left') {
@@ -184,12 +239,14 @@ wss.on('connection', (ws) => {
184
239
  }
185
240
  const session = pty.spawn(command, commandArgs, {
186
241
  name: 'xterm-256color', cols: message.cols, rows: message.rows,
187
- cwd: process.cwd(), env,
242
+ cwd: config.cwd, env,
188
243
  });
189
244
  sessions.set(message.side, session);
190
245
  if (nodeMeter) {
191
- meters.set(message.side, new ClaudeMeter({ sessionId, cwd: process.cwd(),
192
- side: message.side, role: message.side === 'left' ? 'NATIVE' : 'PENNY' }));
246
+ const Meter = config.variant.includes('codex') ? CodexMeter : ClaudeMeter;
247
+ const role = config.variant.startsWith('penny-') ? 'PENNY' : 'NATIVE';
248
+ meters.set(message.side, new Meter({ sessionId, cwd: config.cwd,
249
+ side: message.side, role, label: config.label, variant: config.variant }));
193
250
  send({ type: 'meter-mode' });
194
251
  if (!meterTimer) meterTimer = setInterval(() => { void pollMeters(); }, 1000);
195
252
  }
@@ -216,12 +273,17 @@ wss.on('connection', (ws) => {
216
273
  server.listen(port, '127.0.0.1', () => {
217
274
  const url = `http://127.0.0.1:${port}`;
218
275
  console.log(`Penny Compare live terminal demo: ${url}`);
219
- console.log(compare ? 'Native Claude and Penny Claude panes with transcript HUDs.' :
220
- claude ? 'Each pane starts Claude Code.' : 'Each pane starts a shell. Type claude to run Claude Code.');
276
+ console.log(compare
277
+ ? `Compare setup ready (${pythonCompare ? 'Python session metering' : 'Node transcript metering'}). Choose both panes in the browser.`
278
+ : claude ? 'Each pane starts Claude Code.' : 'Each pane starts a shell. Type claude to run Claude Code.');
221
279
  if (process.platform === 'win32') {
222
280
  execFile(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `start "" "${url}"`],
223
281
  { windowsHide: true }, (error) => {
224
282
  if (error) console.error(`Could not open the browser automatically: ${error.message}`);
225
283
  });
284
+ } else {
285
+ execFile(process.platform === 'darwin' ? 'open' : 'xdg-open', [url], (error) => {
286
+ if (error) console.error(`Could not open the browser automatically: ${error.message}`);
287
+ });
226
288
  }
227
289
  });
@@ -21,26 +21,92 @@
21
21
  .pane { position: relative; }
22
22
  .meter { display: none; position: absolute; top: 4px; right: 13px; z-index: 2;
23
23
  width: 190px; white-space: pre; color: #9ded83; line-height: 1.15; font-size: 13px;
24
- pointer-events: none; }
24
+ pointer-events: none; padding: 5px 8px; border-radius: 3px;
25
+ background: rgba(12, 12, 16, .82); }
25
26
  .pane.metered .meter { display: block; }
26
27
  .delta { display: none; position: absolute; top: 4px; right: 220px; z-index: 2;
27
28
  color: #9ded83; white-space: nowrap; pointer-events: none; }
28
29
  .pane.metered .delta { display: block; }
29
30
  .xterm { height: 100%; }
30
31
  .xterm-viewport { scrollbar-width: thin; }
32
+ .finish { position: fixed; inset: 25px 0 0; z-index: 5; display: grid; place-items: center;
33
+ padding: 24px; background: rgba(10, 10, 14, .94); }
34
+ .finish[hidden] { display: none; }
35
+ .card { width: min(680px, 100%); border: 1px solid #4d4d52; background: #17171d;
36
+ padding: 28px; box-shadow: 0 16px 60px #0009; }
37
+ .card h1 { margin: 0 0 24px; color: #9ded83; font-size: 18px; letter-spacing: .08em; }
38
+ .metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 24px; }
39
+ .metric { padding: 14px; background: #202027; }
40
+ .metric span { display: block; color: #92929c; margin-bottom: 8px; }
41
+ .metric strong { font-size: 20px; }
42
+ .sides { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; color: #c4c4cc; white-space: pre-line; }
43
+ .report-path { margin-top: 24px; color: #9ded83; overflow-wrap: anywhere; }
44
+ .finish-note { margin-top: 20px; color: #92929c; }
45
+ .setup { position: fixed; inset: 25px 0 0; z-index: 4; display: grid; place-items: center;
46
+ overflow: auto; padding: 24px; background: rgba(10, 10, 14, .97); }
47
+ .setup[hidden] { display: none; }
48
+ .setup-card { width: min(760px, 100%); border: 1px solid #4d4d52; background: #17171d;
49
+ padding: 26px; }
50
+ .setup-card h1 { margin: 0 0 22px; color: #9ded83; font-size: 18px; }
51
+ .setup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
52
+ .setup-field { display: grid; gap: 7px; color: #a5a5ae; }
53
+ .setup-field input, .setup-field select { min-width: 0; padding: 9px; border: 1px solid #4d4d52;
54
+ background: #101015; color: #e5e5ea; font: inherit; }
55
+ .setup-model { margin-top: 16px; }
56
+ .setup-actions { display: flex; align-items: center; gap: 14px; margin-top: 22px; }
57
+ .setup-actions button { padding: 10px 16px; border: 1px solid #9ded83; background: #26362a;
58
+ color: #c8ffd0; font: inherit; cursor: pointer; }
59
+ .setup-actions button:disabled { opacity: .5; cursor: wait; }
60
+ #setup-error { color: #ff8b98; }
61
+ @media (max-width: 600px) { .setup-grid { grid-template-columns: 1fr; } }
31
62
  @media (max-width: 720px) { .status { justify-content: flex-start; overflow-x: auto; padding: 0 8px; } }
32
63
  </style>
33
64
  </head>
34
65
  <body>
35
66
  <div class="status">
36
67
  <button id="sync">SYNC ON ^⇧S</button>
37
- <button id="exit">EXIT ^⇧X</button>
68
+ <button id="exit" disabled>EXIT ^⇧X</button>
38
69
  <span id="connection" style="color:#77777e">connecting…</span>
39
70
  </div>
40
71
  <main class="panes">
41
72
  <section class="pane focused"><div class="meter" id="left-meter"></div><div class="terminal" id="native"></div></section>
42
73
  <section class="pane"><div class="delta" id="delta"></div><div class="meter" id="right-meter"></div><div class="terminal" id="penny"></div></section>
43
74
  </main>
75
+ <section class="setup" id="setup">
76
+ <form class="setup-card" id="setup-form">
77
+ <h1>PENNY COMPARE</h1>
78
+ <div class="setup-grid">
79
+ <label class="setup-field">Left pane
80
+ <select id="left-variant"></select>
81
+ </label>
82
+ <label class="setup-field">Right pane
83
+ <select id="right-variant"></select>
84
+ </label>
85
+ <label class="setup-field">Left directory
86
+ <input id="left-cwd" autocomplete="off" required>
87
+ </label>
88
+ <label class="setup-field">Right directory
89
+ <input id="right-cwd" autocomplete="off" required>
90
+ </label>
91
+ <label class="setup-field">Left label <input id="left-label" placeholder="NATIVE or PENNY"></label>
92
+ <label class="setup-field">Right label <input id="right-label" placeholder="NATIVE or PENNY"></label>
93
+ </div>
94
+ <label class="setup-field setup-model">Model (both panes)
95
+ <input id="model" list="model-options" placeholder="default">
96
+ <datalist id="model-options"></datalist>
97
+ </label>
98
+ <div class="setup-actions"><button id="launch" type="submit" disabled>Start comparison</button><span id="setup-error"></span></div>
99
+ </form>
100
+ </section>
101
+ <section class="finish" id="finish" hidden aria-live="polite">
102
+ <div class="card">
103
+ <h1 id="finish-title">PENNY COMPARISON</h1>
104
+ <div class="metrics" id="finish-metrics"></div>
105
+ <div class="sides" id="finish-sides"></div>
106
+ <div class="report-path" id="finish-path"></div>
107
+ <div class="finish-note" id="finish-note">Comparison complete. You can close this tab.</div>
108
+ </div>
109
+ </section>
44
110
  <script src="/xterm.js"></script>
45
111
  <script src="/addon-fit.js"></script>
46
112
  <script>
@@ -61,36 +127,59 @@
61
127
  native.open(document.getElementById('native'));
62
128
  penny.open(document.getElementById('penny'));
63
129
  let sync = true;
130
+ let pendingConfig = null;
64
131
  const panes = document.querySelectorAll('.pane');
65
132
  const send = message => { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); };
66
- const socket = new WebSocket(`ws://${location.host}/terminal`);
133
+ let socket;
67
134
  const setFocus = index => { panes.forEach((p, i) => p.classList.toggle('focused', i === index)); (index ? penny : native).focus(); };
68
135
  const setSync = value => {
69
136
  sync = value;
70
137
  document.getElementById('sync').textContent = sync ? 'SYNC ON ^⇧S' : 'sync off ^⇧S';
71
138
  };
72
139
  const exit = () => {
140
+ if (document.getElementById('exit').disabled) return;
73
141
  send({ type: 'exit' });
74
142
  document.getElementById('connection').textContent = 'saving report…';
75
143
  document.getElementById('exit').disabled = true;
76
144
  };
77
145
  function resize() {
146
+ if (!socket || socket.readyState !== WebSocket.OPEN) return;
78
147
  for (const [side, terminal] of [['left', native], ['right', penny]]) {
79
148
  fit[side].fit();
80
149
  send({ type: 'resize', side, cols: terminal.cols, rows: terminal.rows });
81
150
  }
82
151
  }
83
- socket.addEventListener('open', () => {
152
+ function connect() {
153
+ socket = new WebSocket(`ws://${location.host}/terminal`);
154
+ socket.addEventListener('open', () => {
84
155
  document.getElementById('connection').textContent = 'live PTYs';
85
156
  resize();
86
- for (const [side, terminal] of [['left', native], ['right', penny]]) {
87
- send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
88
- }
89
157
  setFocus(0);
90
- });
158
+ document.getElementById('launch').disabled = false;
159
+ if (!isCompare) {
160
+ document.getElementById('setup').hidden = true;
161
+ document.getElementById('exit').disabled = false;
162
+ for (const [side, terminal] of [['left', native], ['right', penny]])
163
+ send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
164
+ }
165
+ });
91
166
  socket.addEventListener('message', event => {
92
167
  const message = JSON.parse(event.data);
93
168
  const terminal = message.side === 'left' ? native : penny;
169
+ if (message.type === 'configured' && pendingConfig) {
170
+ for (const [side, terminal] of [['left', native], ['right', penny]])
171
+ send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
172
+ pendingConfig = null;
173
+ document.getElementById('launch').disabled = false;
174
+ document.getElementById('exit').disabled = false;
175
+ document.getElementById('setup').hidden = true;
176
+ resize();
177
+ }
178
+ if (message.type === 'configuration-error') {
179
+ pendingConfig = null;
180
+ document.getElementById('launch').disabled = false;
181
+ document.getElementById('setup-error').textContent = message.error;
182
+ }
94
183
  if (message.type === 'output') terminal.write(message.data);
95
184
  if (message.type === 'exit') terminal.write(`\r\n[process exited: ${message.code}]\r\n`);
96
185
  if (message.type === 'error') terminal.write(`\r\n[launch failed: ${message.error}]\r\n`);
@@ -102,7 +191,7 @@
102
191
  const data = message.data;
103
192
  const total = data.totals;
104
193
  document.getElementById(`${data.side}-meter`).textContent =
105
- `${data.role} ${(data.model || 'waiting').split('/').pop()}\n` +
194
+ `${data.label || data.role} ${(data.model || 'waiting').split('/').pop()}\n` +
106
195
  `read ${total.cache_read.toLocaleString().padStart(11)}\n` +
107
196
  `write ${total.cache_write.toLocaleString().padStart(11)}\n` +
108
197
  `in ${total.fresh_input.toLocaleString().padStart(11)}\n` +
@@ -119,26 +208,88 @@
119
208
  const metric = (amount, percent, unit) =>
120
209
  `${unit}${unit ? Math.abs(amount).toFixed(2) : Math.abs(amount).toLocaleString()}` +
121
210
  ` ${amount >= 0 ? 'saved' : 'extra'} (${Math.abs(percent).toFixed(1)}%)`;
122
- native.write(`\r\n\x1b[1;32mPENNY COMPARISON\x1b[0m\r\n` +
123
- `${metric(savings.tokens, savings.token_percent, '')} tokens\r\n` +
124
- `${metric(savings.cost, savings.cost_percent, '$')}\r\n` +
125
- `Report: ${message.path}\r\n`);
211
+ const addMetric = (label, value) => {
212
+ const item = document.createElement('div'); item.className = 'metric';
213
+ const caption = document.createElement('span'); caption.textContent = label;
214
+ const result = document.createElement('strong'); result.textContent = value;
215
+ item.append(caption, result); document.getElementById('finish-metrics').append(item);
216
+ };
217
+ addMetric('COST', metric(savings.cost, savings.cost_percent, '$'));
218
+ addMetric('TOKENS', `${metric(savings.tokens, savings.token_percent, '')} tokens`);
219
+ const sideSummary = side => `${side.calls} calls · ${side.totals.total.toLocaleString()} tokens · $${side.totals.cost.toFixed(2)}`;
220
+ document.getElementById('finish-sides').textContent =
221
+ `NATIVE ${sideSummary(message.native)}\nPENNY ${sideSummary(message.penny)}`;
222
+ document.getElementById('finish-path').textContent = `Report saved: ${message.path}`;
223
+ document.getElementById('finish').hidden = false;
126
224
  document.getElementById('connection').textContent = 'report saved';
127
225
  }
128
226
  if (message.type === 'report-error') {
129
- native.write(`\r\n[report failed: ${message.error}]\r\n`);
227
+ document.getElementById('finish-title').textContent = 'REPORT COULD NOT BE SAVED';
228
+ document.getElementById('finish-metrics').textContent = message.error;
229
+ document.getElementById('finish-note').textContent = 'Comparison sessions have ended. Reload the page to start again.';
230
+ document.getElementById('finish').hidden = false;
130
231
  document.getElementById('connection').textContent = 'report failed';
131
232
  }
132
- });
233
+ });
133
234
  socket.addEventListener('close', () => {
134
235
  if (!document.getElementById('exit').disabled) {
135
236
  document.getElementById('connection').textContent = 'disconnected — reload to reconnect';
136
237
  }
137
238
  });
239
+ }
138
240
  const resizeObserver = new ResizeObserver(resize);
139
241
  panes.forEach(pane => resizeObserver.observe(pane));
140
242
  document.getElementById('sync').onclick = () => setSync(!sync);
141
243
  document.getElementById('exit').onclick = exit;
244
+ const cwd = __PENNY_CWD__;
245
+ const isCompare = __PENNY_COMPARE__;
246
+ const defaultHarness = __PENNY_DEFAULT_HARNESS__;
247
+ const variants = ['claude', 'penny-claude', 'codex', 'penny-codex'];
248
+ for (const side of ['left', 'right']) {
249
+ const select = document.getElementById(`${side}-variant`);
250
+ for (const variant of variants) {
251
+ const option = document.createElement('option'); option.value = variant; option.textContent = variant;
252
+ select.append(option);
253
+ }
254
+ }
255
+ document.getElementById('left-variant').value = defaultHarness;
256
+ document.getElementById('right-variant').value = `penny-${defaultHarness}`;
257
+ document.getElementById('left-cwd').value = `${cwd}/native`;
258
+ document.getElementById('right-cwd').value = `${cwd}/penny`;
259
+ const refreshModels = () => {
260
+ const left = document.getElementById('left-variant').value;
261
+ const right = document.getElementById('right-variant').value;
262
+ const leftCodex = left.includes('codex');
263
+ const rightCodex = right.includes('codex');
264
+ const model = document.getElementById('model');
265
+ model.disabled = leftCodex !== rightCodex;
266
+ if (model.disabled) model.value = '';
267
+ const values = leftCodex && rightCodex
268
+ ? ['default', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini']
269
+ : leftCodex !== rightCodex ? []
270
+ : ['default', 'opus', 'sonnet', 'haiku', 'fable'];
271
+ const list = document.getElementById('model-options'); list.replaceChildren();
272
+ for (const value of values) { const option = document.createElement('option'); option.value = value; list.append(option); }
273
+ };
274
+ document.getElementById('left-variant').onchange = refreshModels;
275
+ document.getElementById('right-variant').onchange = refreshModels;
276
+ refreshModels();
277
+ connect();
278
+ document.getElementById('setup-form').addEventListener('submit', event => {
279
+ event.preventDefault();
280
+ const config = {};
281
+ for (const side of ['left', 'right']) config[side] = {
282
+ variant: document.getElementById(`${side}-variant`).value,
283
+ cwd: document.getElementById(`${side}-cwd`).value.trim(),
284
+ label: document.getElementById(`${side}-label`).value.trim(),
285
+ model: document.getElementById('model').value.trim(),
286
+ };
287
+ if (socket.readyState !== WebSocket.OPEN) return;
288
+ pendingConfig = config;
289
+ document.getElementById('launch').disabled = true;
290
+ document.getElementById('setup-error').textContent = '';
291
+ send({ type: 'configure', config });
292
+ });
142
293
  document.addEventListener('keydown', event => {
143
294
  if (!event.ctrlKey || !event.shiftKey || event.altKey || event.metaKey) return;
144
295
  if (event.code === 'KeyS') { event.preventDefault(); event.stopPropagation(); setSync(!sync); }