pennyrouter 0.3.46 → 0.3.48

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.
@@ -0,0 +1,171 @@
1
+ """Remote file-tool approval probe confined to a disposable test folder.
2
+
3
+ Only Read and Edit are available; every call asks the phone. No shell, MCP,
4
+ permission-mode changes, or arbitrary control commands are forwarded.
5
+ """
6
+
7
+ import argparse
8
+ import asyncio
9
+ import copy
10
+ from pathlib import Path
11
+ import tempfile
12
+
13
+ import httpx
14
+
15
+ from rc_echo import EchoWorker, read_login
16
+ from rc_penny import PennyClaude, PennyWorker, read_profile
17
+
18
+
19
+ def confined_input(cwd, request, tool_input):
20
+ if request.get("tool_name") not in {"Read", "Edit"} or not isinstance(tool_input, dict):
21
+ return False
22
+ path = tool_input.get("file_path")
23
+ if not isinstance(path, str) or not path:
24
+ return False
25
+ root = Path(cwd).resolve()
26
+ target = Path(path)
27
+ if not target.is_absolute():
28
+ target = root / target
29
+ try:
30
+ return target.resolve().is_relative_to(root) and target.resolve() != root
31
+ except (OSError, ValueError):
32
+ return False
33
+
34
+
35
+ class ToolsWorker(PennyWorker):
36
+ def __init__(self, client, oauth, profile, model, cwd):
37
+ super().__init__(client, oauth, profile, model, cwd)
38
+ self.title = "Penny file approvals test"
39
+ self.cwd = cwd
40
+ self.tools = ["Read", "Edit"]
41
+ self.claude = PennyClaude(profile, model, self.local_session, cwd, self.permission)
42
+ self.pending_permissions = {}
43
+ self.permission_futures = {}
44
+ self.inbox = asyncio.Queue(maxsize=10)
45
+ self.queued_ids = set()
46
+ self.active_turn = False
47
+ self.disconnect_after_turns = 0
48
+
49
+ async def permission(self, event):
50
+ request = event.get("request", {})
51
+ denied = {"behavior": "deny", "message": "Only file operations inside this test folder are permitted."}
52
+ if request.get("subtype") != "can_use_tool" or not confined_input(self.cwd, request, request.get("input")):
53
+ return denied
54
+ request_id = event["request_id"]
55
+ future = asyncio.get_running_loop().create_future()
56
+ saved = copy.deepcopy(event)
57
+ self.pending_permissions[request_id] = saved
58
+ self.permission_futures[request_id] = future
59
+ print(f"Phone approval needed: {request['tool_name']}", flush=True)
60
+ try:
61
+ await self.events({**saved, "session_id": self.local_session, "uuid": self.payload("unused")["uuid"]})
62
+ async with asyncio.timeout(180):
63
+ decision = await future
64
+ if decision.get("behavior") == "allow":
65
+ tool_input = decision.get("updatedInput", request["input"])
66
+ if not confined_input(self.cwd, request, tool_input):
67
+ return denied
68
+ # Accept one tool decision, never persistent permission updates.
69
+ return {"behavior": "allow", "updatedInput": tool_input}
70
+ return {"behavior": "deny", "message": "The user denied this tool from the phone."}
71
+ except TimeoutError:
72
+ return {"behavior": "deny", "message": "Phone approval timed out."}
73
+ finally:
74
+ self.pending_permissions.pop(request_id, None)
75
+ self.permission_futures.pop(request_id, None)
76
+
77
+ async def handle(self, envelope):
78
+ event_id = envelope.get("event_id")
79
+ if not event_id or event_id in self.seen or event_id in self.queued_ids:
80
+ return
81
+ payload = envelope.get("payload", {})
82
+ kind = payload.get("type")
83
+ if kind == "user":
84
+ self.queued_ids.add(event_id)
85
+ await self.inbox.put(envelope)
86
+ return
87
+ if kind == "control_response":
88
+ await self.delivery(event_id, "received")
89
+ response = payload.get("response", {})
90
+ request_id = response.get("request_id")
91
+ future = self.permission_futures.get(request_id)
92
+ if future is not None and not future.done():
93
+ decision = response.get("response", {}) if response.get("subtype") == "success" else {}
94
+ future.set_result(decision)
95
+ print(f"Phone decision: {decision.get('behavior', 'deny')}", flush=True)
96
+ else:
97
+ print("Ignored unmatched permission response", flush=True)
98
+ await self.delivery(event_id, "processed")
99
+ self.seen.add(event_id)
100
+ return
101
+ if kind == "control_request" and payload.get("request", {}).get("subtype") == "interrupt":
102
+ await self.delivery(event_id, "received")
103
+ for future in self.permission_futures.values():
104
+ if not future.done():
105
+ future.set_result({"behavior": "deny"})
106
+ if self.active_turn:
107
+ await self.claude.interrupt(payload["request_id"])
108
+ else:
109
+ await self.events(self.payload("control_response", response={
110
+ "subtype": "success", "request_id": payload.get("request_id"), "response": {}}))
111
+ await self.delivery(event_id, "processed")
112
+ self.seen.add(event_id)
113
+ return
114
+ await EchoWorker.handle(self, envelope)
115
+
116
+ async def process_turns(self):
117
+ while not self.stopping:
118
+ envelope = await self.inbox.get()
119
+ self.active_turn = True
120
+ try:
121
+ await EchoWorker.handle(self, envelope)
122
+ if self.disconnect_after_turns and self.replies == self.disconnect_after_turns:
123
+ self.force_reconnect.set()
124
+ finally:
125
+ self.active_turn = False
126
+ self.queued_ids.discard(envelope["event_id"])
127
+ self.inbox.task_done()
128
+
129
+
130
+ async def run(seconds, effort="low", disconnect_after_turns=0):
131
+ profile, model = read_profile()
132
+ async with httpx.AsyncClient(headers={"anthropic-version": "2023-06-01",
133
+ "User-Agent": "penny-rc-file-probe/0.1"}, timeout=httpx.Timeout(30, read=None), trust_env=False) as client:
134
+ response = await client.get(profile["gateway_base_url"].rstrip("/") + "/health", timeout=5)
135
+ if response.status_code != 200:
136
+ raise RuntimeError("Configured Penny gateway is not healthy.")
137
+ with tempfile.TemporaryDirectory(prefix="penny-rc-files-") as cwd:
138
+ (Path(cwd) / "note.txt").write_text("original test content\n")
139
+ worker = ToolsWorker(client, read_login(), profile, model, cwd)
140
+ worker.claude.effort = effort
141
+ worker.disconnect_after_turns = disconnect_after_turns
142
+ tasks = []
143
+ try:
144
+ await worker.start()
145
+ print("TEST FOLDER " + cwd, flush=True)
146
+ tasks = [asyncio.create_task(worker.listen()), asyncio.create_task(worker.heartbeat()),
147
+ asyncio.create_task(worker.process_turns())]
148
+ done, _ = await asyncio.wait(tasks, timeout=seconds, return_when=asyncio.FIRST_COMPLETED)
149
+ for task in done:
150
+ task.result()
151
+ finally:
152
+ for task in tasks:
153
+ task.cancel()
154
+ await asyncio.gather(*tasks, return_exceptions=True)
155
+ await worker.close()
156
+
157
+
158
+ if __name__ == "__main__":
159
+ parser = argparse.ArgumentParser(description=__doc__)
160
+ parser.add_argument("--seconds", type=int, default=900)
161
+ parser.add_argument("--effort", choices=["low", "medium", "high"], default="low")
162
+ parser.add_argument("--disconnect-after-turns", type=int, default=0,
163
+ help="Simulate one three-second stream drop after this many completed turns")
164
+ args = parser.parse_args()
165
+ try:
166
+ asyncio.run(run(max(1, min(args.seconds, 1800)), args.effort, args.disconnect_after_turns))
167
+ except KeyboardInterrupt:
168
+ pass
169
+ except (RuntimeError, TimeoutError, httpx.HTTPError):
170
+ print("File approval probe stopped after a protocol, gateway, or timeout failure.")
171
+ raise SystemExit(1)
@@ -84,6 +84,10 @@ function emptyTotals() {
84
84
  fresh_input: 0, output: 0, reasoning_output: 0, total: 0, cost: 0 };
85
85
  }
86
86
 
87
+ function emptySideCalls() {
88
+ return { count: 0, cost: 0, cache_read: 0, cache_write: 0, fresh_input: 0, output: 0, total: 0, models: {} };
89
+ }
90
+
87
91
  export class ClaudeMeter {
88
92
  constructor({ sessionId, cwd, side, role, label, variant, pricingProfile = 'standard', options = {} }) {
89
93
  this.sessionId = sessionId;
@@ -106,7 +110,7 @@ export class ClaudeMeter {
106
110
  this.transcriptPrints = new Map();
107
111
  this.chargedPrints = new Map();
108
112
  this.telemetry = new Map();
109
- this.sideCalls = { count: 0, cost: 0, models: {} };
113
+ this.sideCalls = emptySideCalls();
110
114
  this.localReplyIds = new Set();
111
115
  }
112
116
 
@@ -162,6 +166,11 @@ export class ClaudeMeter {
162
166
  byModel.calls += 1; byModel.tokens += total; byModel.cost += cost;
163
167
  this.sideCalls.count += 1;
164
168
  this.sideCalls.cost += cost;
169
+ this.sideCalls.cache_read += read;
170
+ this.sideCalls.cache_write += write;
171
+ this.sideCalls.fresh_input += input;
172
+ this.sideCalls.output += output;
173
+ this.sideCalls.total += total;
165
174
  }
166
175
  }
167
176
 
@@ -287,11 +296,17 @@ export class ClaudeMeter {
287
296
  } finally { await handle.close(); }
288
297
  }
289
298
 
299
+ // `totals` is everything billed; `main` leaves out the side calls.
290
300
  snapshot() {
301
+ const side = this.sideCalls;
302
+ const main = { ...this.totals, cache_read: this.totals.cache_read - side.cache_read,
303
+ cache_write: this.totals.cache_write - side.cache_write, write_5m: this.totals.write_5m - side.cache_write,
304
+ fresh_input: this.totals.fresh_input - side.fresh_input, output: this.totals.output - side.output,
305
+ total: this.totals.total - side.total, cost: this.totals.cost - side.cost };
291
306
  return { side: this.side, role: this.role, label: this.label, variant: this.variant, options: this.options,
292
307
  harness: 'claude-code', cwd: this.cwd, pricing_profile: this.pricingProfile, session_id: this.sessionId,
293
308
  transcript_path: this.transcriptPath, model: this.model, call_count: this.calls.length,
294
- totals: { ...this.totals }, local_replies: this.localReplyIds.size,
309
+ totals: { ...this.totals }, main, local_replies: this.localReplyIds.size,
295
310
  side_calls: { ...this.sideCalls, models: structuredClone(this.sideCalls.models) } };
296
311
  }
297
312
  }
@@ -454,7 +469,7 @@ export function galleryReport(left, right, { environment, harnessVersions }) {
454
469
  const penny = sides.find((side) => side.role === 'PENNY');
455
470
  if (!native || !penny || native.harness !== penny.harness) return null;
456
471
  const side = (snapshot) => ({ model: snapshot.model, call_count: snapshot.call_count, totals: snapshot.totals,
457
- side_calls: snapshot.side_calls || { count: 0, cost: 0, models: {} } });
472
+ side_calls: snapshot.side_calls || emptySideCalls() });
458
473
  return JSON.stringify({ version: 1, harness: native.harness,
459
474
  harness_version: harnessVersions?.[native.harness === 'codex' ? 'codex' : 'claude'] || '',
460
475
  environment, native: side(native), penny: side(penny), finished_at: new Date().toISOString() });
@@ -31,6 +31,7 @@
31
31
  .bar .sync-on { color: var(--accent); border-color: #547a50; background: var(--accent-wash); }
32
32
  .bar .sync-off { color: #a0a0aa; border-color: #62626d; background: #292930; }
33
33
  #delta { color: var(--accent); }
34
+ .pane header #delta { margin-left: auto; white-space: nowrap; }
34
35
  #versions { margin-left: auto; color: #92929c; font-size: 12px; }
35
36
  #status { color: #b8b8c3; }
36
37
  @media (max-width: 760px) { .bar { overflow-x: auto; } #versions { display: none; } }
@@ -84,7 +85,7 @@
84
85
  .meter .field.read strong { color: var(--accent); }
85
86
  .meter .field.write strong { color: var(--warn); }
86
87
  .meter .field.in strong, .meter .field.out strong { color: #75ec45; }
87
- .meter .field.side { color: #77777e; }
88
+ .meter .field.main, .meter .field.side { color: #77777e; }
88
89
  .meter .wait { display: none; color: #77777e; }
89
90
  .meter.waiting .wait { display: block; }
90
91
  .meter.waiting .field { display: none; }
@@ -214,7 +215,6 @@
214
215
  <button id="finish" disabled>Finish comparison</button>
215
216
  <button id="download-logs" hidden>Download logs</button>
216
217
  <button id="add-to-gallery" disabled title="Finish this comparison and share it in the PennyRouter gallery">Add to gallery</button>
217
- <span id="delta" aria-live="polite"></span>
218
218
  <span id="versions"></span>
219
219
  <span id="status">Connecting…</span>
220
220
  </div>
@@ -225,6 +225,7 @@
225
225
  <div class="file-list" id="left-file-list" hidden><p>HTML files the agent writes will appear here.</p></div></div>
226
226
  </header><div class="meter" id="left-meter"></div><div class="terminal" id="left"></div></section>
227
227
  <section class="pane" id="right-pane"><header><strong class="label" id="right-label">PENNY</strong><span class="chip" id="right-chip" hidden></span>
228
+ <span id="delta" aria-live="polite"></span>
228
229
  <div class="files" id="right-files"><button class="newest" id="right-newest" hidden></button>
229
230
  <button class="count" id="right-files-count" aria-expanded="false" aria-controls="right-file-list">No files</button>
230
231
  <div class="file-list" id="right-file-list" hidden><p>HTML files the agent writes will appear here.</p></div></div>
@@ -801,6 +802,9 @@
801
802
  // Same abbreviation as hud.py: 1.2k, 3.45M.
802
803
  const abbreviate = (value) => value >= 1_000_000 ? `${(value / 1_000_000).toFixed(2)}M`
803
804
  : value >= 1_000 ? `${(value / 1_000).toFixed(1)}k` : String(value);
805
+ // Cents alone would round a cheap side call to $0.00.
806
+ const dollars = (value) => `$${value.toLocaleString('en-US',
807
+ { minimumFractionDigits: value < 1 ? 4 : 2, maximumFractionDigits: value < 1 ? 4 : 2 })}`;
804
808
  const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
805
809
  const meters = {};
806
810
  const buildMeter = (side) => {
@@ -812,15 +816,16 @@
812
816
  model.append(role, name);
813
817
  root.append(model);
814
818
  const amounts = {};
815
- for (const label of ['read', 'write', 'in', 'out', 'total', 'side', 'cost']) {
816
- const field = document.createElement('span'); field.className = `field ${label}`;
819
+ // Token rows count the main thread only. Background calls Claude Code makes outside the
820
+ // transcript (auto-mode reviewer, titles) show only as `side` cost; the last row is main + side.
821
+ for (const [key, label] of [['read', 'read'], ['write', 'write'], ['in', 'in'], ['out', 'out'],
822
+ ['total', 'total'], ['main', 'main'], ['side', 'side'], ['cost', 'total']]) {
823
+ const field = document.createElement('span'); field.className = `field ${key}`;
817
824
  field.append(document.createTextNode(`${label} `));
818
- amounts[label] = document.createElement('strong');
819
- field.append(amounts[label]);
825
+ amounts[key] = document.createElement('strong');
826
+ field.append(amounts[key]);
820
827
  root.append(field);
821
828
  }
822
- // Background calls (auto-mode reviewer, titles) that Claude Code makes outside the transcript.
823
- amounts.side.parentElement.hidden = true;
824
829
  // Zeros would read as a measurement; the pane has simply not seen a turn yet.
825
830
  const wait = document.createElement('span'); wait.className = 'wait'; wait.textContent = 'waiting for first turn';
826
831
  root.append(wait);
@@ -844,7 +849,7 @@
844
849
  amounts.out.textContent = abbreviate(rolled.output);
845
850
  amounts.total.textContent = abbreviate(rolled.cache_read + rolled.cache_write +
846
851
  rolled.fresh_input + rolled.output);
847
- amounts.cost.textContent = `$${meter.cost.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
852
+ amounts.cost.textContent = dollars(meter.cost);
848
853
  if (Object.keys(rolled).some((key) => rolled[key] !== meter.targets[key])) settled = false;
849
854
  }
850
855
  if (settled) lastFrame = 0;
@@ -853,15 +858,15 @@
853
858
  const showMeter = (snapshot) => {
854
859
  const meter = meters[snapshot.side] ||= buildMeter(snapshot.side);
855
860
  const { totals } = snapshot;
861
+ const main = snapshot.main || totals;
856
862
  meter.role.textContent = snapshot.label || snapshot.role;
857
863
  meter.name.textContent = (snapshot.model || 'waiting').split('/').pop();
858
- meter.targets = { cache_read: totals.cache_read, cache_write: totals.cache_write,
859
- fresh_input: totals.fresh_input, output: totals.output };
864
+ meter.targets = { cache_read: main.cache_read, cache_write: main.cache_write,
865
+ fresh_input: main.fresh_input, output: main.output };
860
866
  meter.cost = totals.cost;
861
867
  const side = snapshot.side_calls;
862
- const sideField = meter.amounts.side.parentElement;
863
- sideField.hidden = !side?.count;
864
- if (side?.count) meter.amounts.side.textContent = `${side.count} · $${side.cost.toFixed(2)}`;
868
+ meter.amounts.main.textContent = dollars(main.cost);
869
+ meter.amounts.side.textContent = dollars(side?.cost || 0);
865
870
  meter.root.classList.add('has-data');
866
871
  meter.root.classList.toggle('waiting', !totals.total);
867
872
  if (!frameRequest) frameRequest = requestAnimationFrame(paintMeters);
@@ -870,8 +875,10 @@
870
875
  const native = meterSnapshots.left?.totals;
871
876
  const penny = meterSnapshots.right?.totals;
872
877
  if (!native || !penny) return;
878
+ const nativeMain = meterSnapshots.left.main || native;
879
+ const pennyMain = meterSnapshots.right.main || penny;
873
880
  const cost = native.cost ? (native.cost - penny.cost) / native.cost * 100 : 0;
874
- const tokens = native.total ? (native.total - penny.total) / native.total * 100 : 0;
881
+ const tokens = nativeMain.total ? (nativeMain.total - pennyMain.total) / nativeMain.total * 100 : 0;
875
882
  $('delta').textContent =
876
883
  `${cost > 0 ? '-' : '+'}${Math.abs(cost).toFixed(0)}% cost ${tokens > 0 ? '-' : '+'}${Math.abs(tokens).toFixed(0)}% tok`;
877
884
  };
@@ -1159,6 +1166,7 @@
1159
1166
  button.disabled = true;
1160
1167
  button.textContent = 'Submitted to gallery';
1161
1168
  }
1169
+ $('publish').hidden = true;
1162
1170
  };
1163
1171
 
1164
1172
  // Messages common to both servers
@@ -1263,8 +1271,9 @@
1263
1271
  // ids), so the alias is what is sent; the label names the model it currently resolves to.
1264
1272
  claude: [['opus', 'Claude Opus 5.5'], ['sonnet', 'Claude Sonnet 5.5'], ['haiku', 'Claude Haiku 4.5'],
1265
1273
  ['fable', 'Claude Fable 5.1']],
1266
- codex: [['gpt-5.6-sol', 'GPT-5.6 Sol'], ['gpt-5.6-terra', 'GPT-5.6 Terra'], ['gpt-5.6-luna', 'GPT-5.6 Luna'],
1267
- ['gpt-5.5', 'GPT-5.5'], ['gpt-5.4', 'GPT-5.4'], ['gpt-5.4-mini', 'GPT-5.4 mini']],
1274
+ codex: [['gpt-6.1-sol', 'GPT-6.1 Sol'], ['gpt-6-astra', 'GPT-6 Astra'], ['gpt-6-sol', 'GPT-6 Sol'],
1275
+ ['gpt-6-luna', 'GPT-6 Luna'], ['gpt-5.6-sol', 'GPT-5.6 Sol'], ['gpt-5.6-terra', 'GPT-5.6 Terra'],
1276
+ ['gpt-5.6-luna', 'GPT-5.6 Luna'], ['gpt-5.5', 'GPT-5.5']],
1268
1277
  };
1269
1278
  $('left-variant').value = config.defaultHarness;
1270
1279
  $('right-variant').value = `penny-${config.defaultHarness}`;
@@ -170,7 +170,8 @@ function validateEnvironment(environment = {}) {
170
170
  const safeModel = /^[A-Za-z0-9._/-]{1,160}$/;
171
171
 
172
172
  async function validateConfig(config) {
173
- const codexModels = ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini'];
173
+ const codexModels = ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', 'gpt-5.6-sol', 'gpt-5.6-terra',
174
+ 'gpt-5.6-luna', 'gpt-5.5'];
174
175
  const claudeModels = ['opus', 'sonnet', 'haiku', 'fable'];
175
176
  if (!config || !variants.has(config.variant) || typeof config.cwd !== 'string' ||
176
177
  config.cwd.length > 4096 || typeof config.label !== 'string' || config.label.length > 80 ||