pennyrouter 0.3.43 → 0.3.45

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.43",
3
+ "version": "0.3.45",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
package/src/launch.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  readFile,
9
9
  rm,
10
10
  readdir,
11
+ rename,
11
12
  stat,
12
13
  symlink,
13
14
  unlink,
@@ -290,6 +291,8 @@ async function findInstalledPackage(name) {
290
291
  }
291
292
  }
292
293
 
294
+ const REPLACED_PREFIX = ".replaced-node-modules-";
295
+
293
296
  /** Install the bundled, repository-independent Penny Compare tools. */
294
297
  export async function installPennyCompare() {
295
298
  if (!(process.platform === "darwin" || process.platform === "linux" || process.platform === "win32")) {
@@ -303,9 +306,26 @@ export async function installPennyCompare() {
303
306
  return { supported: true, installed: false, path: destination };
304
307
  }
305
308
  await mkdir(destination, { recursive: true, mode: 0o700 });
309
+ // A running Penny Compare keeps node-pty's native module loaded, and Windows will not delete
310
+ // a loaded module. Move the old dependencies aside first: a running copy then stops the
311
+ // install before anything is removed, instead of leaving a half-deleted install behind.
312
+ try {
313
+ await rename(join(destination, "web-demo", "node_modules"), join(destination, `${REPLACED_PREFIX}${Date.now()}`));
314
+ } catch (error) {
315
+ if (["EPERM", "EBUSY", "EACCES"].includes(error.code)) {
316
+ throw new Error("Penny Compare is still running, so its files cannot be replaced. " +
317
+ "Stop it (Ctrl+C in its terminal, or close that window), then run `npx pennyrouter compare` again.");
318
+ }
319
+ if (error.code !== "ENOENT") throw error;
320
+ }
306
321
  // Preserve user state while replacing the shipped code on every CLI upgrade.
307
322
  for (const entry of await readdir(destination)) {
308
323
  if (entry === ".env" || entry === "reports") continue;
324
+ if (entry.startsWith(REPLACED_PREFIX)) {
325
+ // Dependencies moved aside above, or by an earlier install whose copy was still running.
326
+ await rm(join(destination, entry), { recursive: true, force: true }).catch(() => {});
327
+ continue;
328
+ }
309
329
  await rm(join(destination, entry), { recursive: true, force: true });
310
330
  }
311
331
  for (const entry of await readdir(source)) {
@@ -16,6 +16,7 @@ import {
16
16
  createCodexDesktopHome,
17
17
  configurePennyHarness,
18
18
  fileMode,
19
+ installPennyCompare,
19
20
  installPennyRuntime,
20
21
  isClaudeDesktopRunning,
21
22
  isCodexDesktopRunning,
@@ -106,7 +107,7 @@ async function main() {
106
107
  assert.equal(second.changed, false);
107
108
  assert.equal(await fileMode(pennyShimPath()), 0o755);
108
109
  assert.ok(fs.existsSync(path.join(pennyRuntimeDir(), "bin", "penny.js")));
109
- for (const asset of ["terminal-unicode.js", "artifacts.mjs", "artifact-preview.html",
110
+ for (const asset of ["terminal-unicode.js", "artifacts.mjs", "artifact-preview.html", "telemetry.mjs", "gallery-submit.mjs", "pane-options.mjs", "compare.html",
110
111
  "node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js"]) {
111
112
  assert.ok(fs.existsSync(path.join(pennyCompareDir(), "web-demo", asset)), `compare installs ${asset}`);
112
113
  }
@@ -115,6 +116,20 @@ async function main() {
115
116
  fs.realpathSync(path.join(pennyCompareDir(), "penny-compare")),
116
117
  );
117
118
  assert.ok(!fs.existsSync(staleSkill), "runtime upgrade removes stale plugin files");
119
+ // A running copy that holds its dependencies open (as Windows does for node-pty's native
120
+ // module) stops a reinstall before anything is removed. A read-only folder blocks the same move.
121
+ const compareDemo = path.join(pennyCompareDir(), "web-demo");
122
+ fs.chmodSync(compareDemo, 0o555);
123
+ try {
124
+ await assert.rejects(installPennyCompare(), /still running/);
125
+ assert.ok(fs.existsSync(path.join(compareDemo, "server.mjs")), "a blocked reinstall leaves the install intact");
126
+ } finally {
127
+ fs.chmodSync(compareDemo, 0o755);
128
+ }
129
+ await installPennyCompare();
130
+ assert.ok(fs.existsSync(path.join(compareDemo, "node_modules", "node-pty")), "reinstall restores dependencies");
131
+ assert.deepEqual(fs.readdirSync(pennyCompareDir()).filter((entry) => entry.startsWith(".replaced-")), [],
132
+ "moved-aside dependencies are removed once nothing holds them");
118
133
  const rc = fs.readFileSync(path.join(home, ".zshrc"), "utf8");
119
134
  assert.equal((rc.match(/>>> pennyrouter PATH >>>/g) || []).length, 1);
120
135
 
@@ -0,0 +1,106 @@
1
+ {
2
+ "note": "GovCloud per-token rates, maintained by hand. Models listed here override rates.json under the govcloud pricing profile; other models keep their standard rate.",
3
+ "order": [
4
+ "input",
5
+ "cache_read",
6
+ "cache_write_5m",
7
+ "cache_write_1h",
8
+ "output"
9
+ ],
10
+ "unit": "usd_per_token",
11
+ "models": {
12
+ "claude-opus-5": {
13
+ "rates": [
14
+ 5.5e-06,
15
+ 5.5e-07,
16
+ 6.875e-06,
17
+ 1.1e-05,
18
+ 2.75e-05
19
+ ]
20
+ },
21
+ "claude-opus-4-8": {
22
+ "rates": [
23
+ 5.5e-06,
24
+ 5.5e-07,
25
+ 6.875e-06,
26
+ 1.1e-05,
27
+ 2.75e-05
28
+ ]
29
+ },
30
+ "claude-sonnet-5": {
31
+ "rates": [
32
+ 2.2e-06,
33
+ 2.2e-07,
34
+ 2.75e-06,
35
+ 4.4e-06,
36
+ 1.1e-05
37
+ ]
38
+ },
39
+ "claude-haiku-4-5": {
40
+ "rates": [
41
+ 1.1e-06,
42
+ 1.1e-07,
43
+ 1.375e-06,
44
+ 2.2e-06,
45
+ 5.5e-06
46
+ ]
47
+ },
48
+ "gpt-5.6-luna": {
49
+ "rates": [
50
+ 2.2e-07,
51
+ 2.2e-08,
52
+ 2.75e-07,
53
+ 2.75e-07,
54
+ 1.32e-06
55
+ ],
56
+ "above": {
57
+ "threshold": 272000,
58
+ "rates": [
59
+ 4.4e-07,
60
+ 4.4e-08,
61
+ 5.5e-07,
62
+ 5.5e-07,
63
+ 1.98e-06
64
+ ]
65
+ }
66
+ },
67
+ "gpt-5.6-terra": {
68
+ "rates": [
69
+ 2.2e-06,
70
+ 2.2e-07,
71
+ 2.75e-06,
72
+ 2.75e-06,
73
+ 1.32e-05
74
+ ],
75
+ "above": {
76
+ "threshold": 272000,
77
+ "rates": [
78
+ 4.4e-06,
79
+ 4.4e-07,
80
+ 5.5e-06,
81
+ 5.5e-06,
82
+ 1.98e-05
83
+ ]
84
+ }
85
+ },
86
+ "gpt-5.6-sol": {
87
+ "rates": [
88
+ 4.4e-06,
89
+ 4.4e-07,
90
+ 5.5e-06,
91
+ 5.5e-06,
92
+ 2.2e-05
93
+ ],
94
+ "above": {
95
+ "threshold": 272000,
96
+ "rates": [
97
+ 8.8e-06,
98
+ 8.8e-07,
99
+ 1.1e-05,
100
+ 1.1e-05,
101
+ 3.3e-05
102
+ ]
103
+ }
104
+ }
105
+ }
106
+ }
@@ -11,11 +11,13 @@ import json
11
11
  from pathlib import Path
12
12
 
13
13
 
14
- def _load_standard_rates():
15
- """Standard per-token rates from rates.json (scripts/generate_compare_rates.py), the same
16
- file the web-demo meters read. Returns (flat rates, tiered rates) keyed by model id; a tier
17
- entry is (inclusive threshold, <= threshold rates, > threshold rates)."""
18
- document = json.loads((Path(__file__).with_name("rates.json")).read_text())
14
+ def _load_rates(filename: str):
15
+ """Per-token rates from a JSON schedule beside this file. rates.json holds the standard
16
+ schedule (scripts/generate_compare_rates.py); govcloud-rates.json holds the hand-maintained
17
+ GovCloud overrides. The web-demo meters read the same files. Returns (flat rates, tiered
18
+ rates) keyed by model id; a tier entry is (inclusive threshold, <= threshold rates,
19
+ > threshold rates)."""
20
+ document = json.loads((Path(__file__).with_name(filename)).read_text())
19
21
  flat, tiered = {}, {}
20
22
  for name, model in document["models"].items():
21
23
  flat[name] = tuple(model["rates"])
@@ -27,35 +29,11 @@ def _load_standard_rates():
27
29
 
28
30
  # input, cache_read, write_5m, write_1h, output. OpenAI does not offer a 1-hour cache tier;
29
31
  # its write rate applies uniformly.
30
- RATES, STANDARD_TIERED_RATES = _load_standard_rates()
32
+ RATES, STANDARD_TIERED_RATES = _load_rates("rates.json")
31
33
 
32
34
  # --native-proxy means the comparison is measuring the GovCloud route. Pricing remains the
33
35
  # same on both panes, but both use this schedule instead of the standard one.
34
- GOVCLOUD_RATES = {
35
- "claude-opus-5": (5.50e-06, 5.50e-07, 6.875e-06, 1.10e-05, 2.75e-05),
36
- "claude-opus-4-8": (5.50e-06, 5.50e-07, 6.875e-06, 1.10e-05, 2.75e-05),
37
- "claude-sonnet-5": (2.20e-06, 2.20e-07, 2.75e-06, 4.40e-06, 1.10e-05),
38
- "claude-haiku-4-5": (1.10e-06, 1.10e-07, 1.375e-06, 2.20e-06, 5.50e-06),
39
- }
40
-
41
- # The GovCloud schedule for the same tiered GPT models.
42
- GOVCLOUD_TIERED_RATES = {
43
- "gpt-5.6-luna": (
44
- 272_000,
45
- (0.22e-06, 0.022e-06, 0.275e-06, 0.275e-06, 1.32e-06),
46
- (0.44e-06, 0.044e-06, 0.55e-06, 0.55e-06, 1.98e-06),
47
- ),
48
- "gpt-5.6-terra": (
49
- 272_000,
50
- (2.20e-06, 0.22e-06, 2.75e-06, 2.75e-06, 13.20e-06),
51
- (4.40e-06, 0.44e-06, 5.50e-06, 5.50e-06, 19.80e-06),
52
- ),
53
- "gpt-5.6-sol": (
54
- 272_000,
55
- (4.40e-06, 0.44e-06, 5.50e-06, 5.50e-06, 22.00e-06),
56
- (8.80e-06, 0.88e-06, 11.00e-06, 11.00e-06, 33.00e-06),
57
- ),
58
- }
36
+ GOVCLOUD_RATES, GOVCLOUD_TIERED_RATES = _load_rates("govcloud-rates.json")
59
37
 
60
38
  CONTEXT_WINDOWS = {
61
39
  "claude-opus-5": 200_000,
@@ -214,6 +214,13 @@ class UsageReader:
214
214
  return
215
215
  state.counted.add(mid)
216
216
 
217
+ # A reply the gateway answered itself carries an estimate of the whole context as fresh
218
+ # input, and nothing was billed for it. The gateway marks these replies with a
219
+ # `penny_local_` request id and a `msg_swallow_` message id.
220
+ if (str(obj.get("requestId") or "").startswith("penny_local_")
221
+ or str(mid or "").startswith("msg_swallow_")):
222
+ return
223
+
217
224
  read = int(usage.get("cache_read_input_tokens") or 0)
218
225
  write = int(usage.get("cache_creation_input_tokens") or 0)
219
226
  creation = usage.get("cache_creation") or {}
@@ -15,16 +15,16 @@ To launch Claude Code in both panes immediately:
15
15
  npm start -- --claude
16
16
  ```
17
17
 
18
- To run the current Penny Compare measurement code in the panes:
18
+ To run Penny Compare in the panes:
19
19
 
20
20
  ```sh
21
21
  npm start -- --compare
22
22
  ```
23
23
 
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`.
24
+ This opens a setup screen with the Claude/Codex pane choices, folders, labels, shared model, and environment (provider, proxy, pricing). The default is native Claude on the left and Penny Claude on the right, using `native` and `penny` folders under the directory `npm start` was run from; missing folders are created. Each pane's meter reads Claude or Codex transcripts and prices calls in Node on every platform, with no Python or tmux requirement. Claude panes also report background model calls (such as the auto-mode reviewer) to this server over local OpenTelemetry, which the meter adds as a "side" line. 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
- 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.
26
+ A Penny Claude pane has an **Options** section under its picker, set separately for each side. **Cache TTL** chooses 5 minutes (the default) or 1 hour for the conversation cache by setting Claude Code's `FORCE_PROMPT_CACHING_5M` or `ENABLE_PROMPT_CACHING_1H` for that pane; the shared prefix is always 1 hour. The choice is shown in the pane header and saved in the report. Options are defined in `pane-options.mjs`.
27
27
 
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`.
28
+ 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. `--compare-node` is accepted as an older name for `--compare`.
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.