relay-codex 0.7.0 → 0.7.1

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
@@ -69,7 +69,8 @@ Use a session's menu to switch accounts after its work finishes. Account menus c
69
69
  enable or disable future routing without stopping current tasks. Actions show
70
70
  their result, and stopped workers offer a restart.
71
71
  Account menus also show earned usage resets when available. Choose **Use available
72
- reset…** and confirm to spend one; Relay refreshes allowance afterward. Recovery
72
+ reset…** and confirm to spend one; Relay refreshes allowance afterward. The button
73
+ is enabled only with weekly allowance at 0% and its reset more than 48 hours away. Recovery
73
74
  never spends these resets automatically.
74
75
 
75
76
  For tab completion in Zsh, run `source <(relay completion zsh)` after `compinit`.
package/docs/commands.md CHANGED
@@ -51,13 +51,17 @@ A session cap of zero means unlimited. Removing an account retains its login
51
51
  unless you request `--revoke`.
52
52
 
53
53
  The account menu shows **Usage resets available** and **Use available reset…**
54
- when Codex reports an earned reset. Choose it and confirm to use one. If the count
54
+ when Codex reports an earned reset. It is enabled only when weekly allowance is
55
+ fully exhausted (0%) and the weekly reset is **more than 48 hours away**. Exactly
56
+ 48 hours, any weekly allowance left, or unknown weekly data keeps it disabled.
57
+ The reason appears below the button. Choose it and confirm to use one. If the count
55
58
  is unknown, choose **Check allowance now** first. Reset availability is separate
56
59
  from purchased workspace credits. Resets are never spent by automatic recovery.
57
60
 
58
61
  The equivalent terminal command is `relay account reset ACCOUNT --yes`. If a
59
62
  response is lost, use the same action again: Relay saves and reuses the pending
60
- request rather than submitting a new redemption. The menu calls this **Check
63
+ request rather than submitting a new redemption. Pending retries must satisfy
64
+ the same weekly rule; their saved request is retained when locked. The menu calls this **Check
61
65
  previous reset…**. An optional `--request-id UUID` also identifies a specific
62
66
  logical attempt. Codex chooses the available reset; Relay checks the returned
63
67
  result and reads allowance again afterward.
@@ -8,8 +8,8 @@ Friends can run `npx relay-codex` or install it with `npm install -g relay-codex
8
8
  The GitHub repository is enough for friends to install or try Relay:
9
9
 
10
10
  ```sh
11
- npm install -g github:blackspider-ops/relay#v0.7.0
12
- npx --yes github:blackspider-ops/relay#v0.7.0 --version
11
+ npm install -g github:blackspider-ops/relay#v0.7.1
12
+ npx --yes github:blackspider-ops/relay#v0.7.1 --version
13
13
  ```
14
14
 
15
15
  `npx` runs packages; it is not a separate publishing service. Publishing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-codex",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Keep your Codex sessions moving between your own accounts.",
5
5
  "license": "MIT",
6
6
  "author": "blackspider-ops",
package/python/Menu.swift CHANGED
@@ -12,6 +12,8 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
12
12
  var reconnect: DispatchWorkItem?
13
13
  var stopping = false
14
14
  var snapshotInFlight = false
15
+ var resetExpiry: DispatchWorkItem?
16
+ var menuOpen = false
15
17
  let command = Bundle.main.object(forInfoDictionaryKey: "RelayCommand") as? String ?? "/usr/bin/false"
16
18
  let state = Bundle.main.object(forInfoDictionaryKey: "RelayState") as? String ?? ""
17
19
 
@@ -140,6 +142,15 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
140
142
  guard let seconds = value as? Double, seconds.isFinite, seconds > 0 else { return nil }
141
143
  return seconds
142
144
  }
145
+ static func resetBlockReason(_ account: [String: Any], now: Double = Date().timeIntervalSince1970) -> String? {
146
+ guard let weekly = account["secondary"] as? [String: Any], let used = (weekly["usedPercent"] as? NSNumber)?.doubleValue, used.isFinite else {
147
+ return "Reset locked: check allowance to confirm the weekly limit and reset time."
148
+ }
149
+ guard used >= 100 else { return "Reset locked: weekly allowance must be fully exhausted (0%)." }
150
+ guard let at = timestamp(weekly["resetsAt"]) else { return "Reset locked: check allowance to confirm the weekly limit and reset time." }
151
+ guard at - now > 48 * 60 * 60 else { return "Reset locked: the weekly reset must be more than 48 hours away." }
152
+ return nil
153
+ }
143
154
  static func accountTimingRows(_ account: [String: Any]) -> [TimingRow] {
144
155
  var rows: [TimingRow] = []
145
156
  if let primary = account["primary"] as? [String: Any], let at = timestamp(primary["resetsAt"]) {
@@ -182,6 +193,7 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
182
193
  updateTimes(menu)
183
194
  if let visible = item.menu { MenuController.synchronise(visible, with: menu) }
184
195
  else { item.menu = menu }
196
+ if menuOpen { scheduleResetExpiry() }
185
197
  }
186
198
  func makeMenu() -> NSMenu {
187
199
  let sessions = current["sessions"] as? [[String: Any]] ?? []
@@ -233,8 +245,10 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
233
245
  if let count = resetCount { text("Usage resets available: \(count)", into: submenu) }
234
246
  else { text("Usage resets: check allowance to see availability.", into: submenu) }
235
247
  if pendingReset || (resetCount ?? 0) > 0 {
248
+ let blocked = MenuController.resetBlockReason(account)
236
249
  let reset = action(pendingReset ? "Check previous reset…" : "Use available reset…", args: ["confirm-reset", id, name, pendingReset ? "pending" : "new"], into: submenu)
237
- reset.isEnabled = !actionRunning && !(account["maintenance"] as? Bool ?? false)
250
+ reset.isEnabled = !actionRunning && !(account["maintenance"] as? Bool ?? false) && blocked == nil
251
+ if let blocked = blocked { text(blocked, into: submenu) }
238
252
  }
239
253
  submenu.addItem(.separator())
240
254
  let enabled = account["enabled"] as? Bool ?? true
@@ -298,12 +312,37 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
298
312
  return menu
299
313
  }
300
314
  func menuWillOpen(_ menu: NSMenu) {
315
+ menuOpen = true
316
+ render()
301
317
  refreshSnapshot()
302
318
  updateTimes(menu)
303
319
  for entry in menu.items where entry.title == "Open at login" {
304
320
  entry.state = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist") ? .on : .off
305
321
  }
306
322
  }
323
+ func scheduleResetExpiry() {
324
+ resetExpiry?.cancel()
325
+ let now = Date().timeIntervalSince1970
326
+ let accounts = current["accounts"] as? [[String: Any]] ?? []
327
+ let boundaries = accounts.compactMap { account -> Double? in
328
+ guard MenuController.resetBlockReason(account, now: now) == nil, let weekly = account["secondary"] as? [String: Any], let at = MenuController.timestamp(weekly["resetsAt"]) else { return nil }
329
+ return at - 48 * 60 * 60
330
+ }
331
+ if let boundary = boundaries.min() {
332
+ let expiry = DispatchWorkItem { [weak self] in self?.render() }
333
+ resetExpiry = expiry
334
+ DispatchQueue.main.asyncAfter(deadline: .now() + max(0, boundary - now), execute: expiry)
335
+ }
336
+ }
337
+ func menuDidClose(_ menu: NSMenu) { menuOpen = false; resetExpiry?.cancel(); resetExpiry = nil }
338
+ func resetAllowed(_ id: String) -> Bool {
339
+ let accounts = current["accounts"] as? [[String: Any]] ?? []
340
+ guard let account = accounts.first(where: { $0["id"] as? String == id }) else {
341
+ actionNotice = "Reset locked: account details are unavailable."; actionFailed = true; render(); return false
342
+ }
343
+ if let reason = MenuController.resetBlockReason(account) { actionNotice = reason; actionFailed = true; render(); return false }
344
+ return true
345
+ }
307
346
  @objc func runAction(_ sender: NSMenuItem) {
308
347
  guard var args = sender.representedObject as? [String], !args.isEmpty else { return }
309
348
  if args[0] == "copy" { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(args[1], forType: .string); return }
@@ -317,12 +356,14 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
317
356
  args = ["retry", args[1], "--wait"]
318
357
  }
319
358
  if args[0] == "confirm-reset" {
359
+ guard resetAllowed(args[1]) else { return }
320
360
  let pending = args[3] == "pending"
321
361
  let alert = NSAlert(); alert.messageText = pending ? "Check the previous reset?" : "Use one usage reset?"
322
362
  alert.informativeText = args[2] + "\n\n" + (pending ? "Relay will check the same pending request. If it already used a reset, another one will not be spent." : "This spends one earned reset on eligible usage limits for this account. Allowance will be checked afterward. Paused tasks and review holds stay paused.")
323
363
  alert.addButton(withTitle: pending ? "Check reset" : "Use reset"); alert.addButton(withTitle: "Cancel")
324
364
  NSApp.activate(ignoringOtherApps: true)
325
365
  guard alert.runModal() == .alertFirstButtonReturn else { return }
366
+ guard resetAllowed(args[1]) else { return }
326
367
  args = ["account", "reset", args[1], "--yes", "--request-id", UUID().uuidString]
327
368
  }
328
369
  actionRunning = true; actionFailed = false; actionNotice = "Applying change…"; render()
@@ -413,11 +454,18 @@ if CommandLine.arguments.contains("--self-check") {
413
454
  let correctEmpty = empty.items.contains { $0.title.contains("No Relay sessions running") } && empty.items.first { $0.identifier?.rawValue == "account:one" }?.state == .off
414
455
  controller.current["sessions"] = originalSessions
415
456
  let originalAccounts = controller.current["accounts"]
416
- controller.current["accounts"] = [["id": "one", "name": "One", "reset_credits": 2], ["id": "two", "name": "Two", "reset_credits": 0], ["id": "unknown", "name": "Unknown"], ["id": "pending", "name": "Pending", "reset_pending": true]]
457
+ controller.current["accounts"] = [["id": "one", "name": "One", "reset_credits": 2, "secondary": ["usedPercent": 100, "resetsAt": Date().timeIntervalSince1970 + 72 * 3600]], ["id": "two", "name": "Two", "reset_credits": 0], ["id": "unknown", "name": "Unknown"], ["id": "pending", "name": "Pending", "reset_pending": true]]
417
458
  let resets = controller.makeMenu()
418
459
  let correctResets = findAction(["confirm-reset", "one", "One", "new"], in: resets)?.isEnabled == true && findAction(["confirm-reset", "two", "Two", "new"], in: resets) == nil && findAction(["confirm-reset", "unknown", "Unknown", "new"], in: resets) == nil && findAction(["confirm-reset", "pending", "Pending", "pending"], in: resets) != nil
419
460
  controller.actionRunning = true
420
461
  let resetLocked = findAction(["confirm-reset", "one", "One", "new"], in: controller.makeMenu())?.isEnabled == false
462
+ let policyNow = 2000000000.0
463
+ let far: [String: Any] = ["secondary": ["usedPercent": 100, "resetsAt": policyNow + 172801]]
464
+ let exact: [String: Any] = ["secondary": ["usedPercent": 100, "resetsAt": policyNow + 172800]]
465
+ let near: [String: Any] = ["secondary": ["usedPercent": 100, "resetsAt": policyNow + 172799]]
466
+ let left: [String: Any] = ["secondary": ["usedPercent": 99.99, "resetsAt": policyNow + 259200]]
467
+ let correctPolicy = MenuController.resetBlockReason(far, now: policyNow) == nil && MenuController.resetBlockReason(exact, now: policyNow) != nil && MenuController.resetBlockReason(near, now: policyNow) != nil && MenuController.resetBlockReason(left, now: policyNow) != nil && MenuController.resetBlockReason([:], now: policyNow) != nil
468
+ let pendingLocked = findAction(["confirm-reset", "pending", "Pending", "pending"], in: resets)?.isEnabled == false
421
469
  controller.current["accounts"] = originalAccounts
422
470
  controller.actionRunning = true; controller.actionNotice = "Applying change…"
423
471
  let pending = controller.makeMenu()
@@ -429,7 +477,8 @@ if CommandLine.arguments.contains("--self-check") {
429
477
  print("Pending action protected and result retained: \(correctPending && retainedResult)")
430
478
  print("In-use accounts and current choice marked: \(correctMarker && correctMultiple && correctEmpty)")
431
479
  print("Available and pending reset actions protected: \(correctResets && resetLocked)")
432
- exit(normal != nil && attention != nil && liveUpdate && safeIdentity && correctTiming && correctChoices && stoppedControls && correctPending && retainedResult && correctMarker && correctMultiple && correctEmpty && correctResets && resetLocked ? 0 : 1)
480
+ print("Weekly zero and more-than-48-hour guard enforced: \(correctPolicy && pendingLocked)")
481
+ exit(normal != nil && attention != nil && liveUpdate && safeIdentity && correctTiming && correctChoices && stoppedControls && correctPending && retainedResult && correctMarker && correctMultiple && correctEmpty && correctResets && resetLocked && correctPolicy && pendingLocked ? 0 : 1)
433
482
  } else {
434
483
  let app = NSApplication.shared
435
484
  let delegate = MenuController(); app.delegate = delegate; app.run()
@@ -114,6 +114,7 @@ def snapshot(state, include_inactive=False):
114
114
  "usage_checked_at": pool.checked_at(label),
115
115
  "reset_credits": resets.available(usage),
116
116
  "reset_pending": resets.pending(state / "accounts" / label),
117
+ "reset_lock_reason": resets.weekly_block_reason(usage),
117
118
  "maintenance": options.get("maintenance", False),
118
119
  "next_check": min(
119
120
  (b["next_check"] for b in blocks if b["account"] == label),
package/python/resets.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import hashlib
4
4
  import json
5
+ import math
5
6
  import time
6
7
  import uuid
7
8
 
@@ -9,6 +10,29 @@ from pool import Pool, blocked, reset_time
9
10
  from settings import atomic_json
10
11
 
11
12
  OUTCOMES = {"reset", "alreadyRedeemed", "nothingToReset", "noCredit"}
13
+ WEEKLY_RESET_BUFFER = 48 * 60 * 60
14
+
15
+
16
+ def weekly_block_reason(snapshot, now=None):
17
+ window = ((snapshot or {}).get("rateLimits") or {}).get("secondary")
18
+ if not isinstance(window, dict):
19
+ return (
20
+ "Reset locked: check allowance to confirm the weekly limit and reset time."
21
+ )
22
+ used, at = window.get("usedPercent"), window.get("resetsAt")
23
+ if type(used) not in (int, float) or not math.isfinite(used):
24
+ return (
25
+ "Reset locked: check allowance to confirm the weekly limit and reset time."
26
+ )
27
+ if used < 100:
28
+ return "Reset locked: weekly allowance must be fully exhausted (0%)."
29
+ if type(at) not in (int, float) or not math.isfinite(at):
30
+ return (
31
+ "Reset locked: check allowance to confirm the weekly limit and reset time."
32
+ )
33
+ if at - (time.time() if now is None else now) <= WEEKLY_RESET_BUFFER:
34
+ return "Reset locked: the weekly reset must be more than 48 hours away."
35
+ return None
12
36
 
13
37
 
14
38
  def available(snapshot):
@@ -122,13 +146,16 @@ async def consume(state, label, binary, request_id=None):
122
146
  )
123
147
  if known and outcome == "reset":
124
148
  outcome = "alreadyRedeemed"
125
- if not retry and not known:
149
+ if not known:
126
150
  current = await rpc.call("account/rateLimits/read")
127
151
  update_allowance(pool, label, current)
152
+ reason = weekly_block_reason(current)
153
+ if reason:
154
+ raise RouterError(reason)
128
155
  count = available(current)
129
- if count == 0:
156
+ if count == 0 and not retry:
130
157
  return {"outcome": "noCredit", "refreshed": True}
131
- if count is None:
158
+ if count is None and not retry:
132
159
  raise RouterError(
133
160
  "Codex did not report reset availability. Check allowance again before using a reset."
134
161
  )
package/python/router.py CHANGED
@@ -24,7 +24,7 @@ from pool import Pool, blocked, reset_time, failure_kind, file_lock
24
24
  from launch import translate, RESUME_FIELDS
25
25
  import settings
26
26
 
27
- VERSION = "0.7.0"
27
+ VERSION = "0.7.1"
28
28
  _support = Path.home() / "Library/Application Support"
29
29
  _legacy = _support / "Codex Accounts"
30
30
  _default_state = _legacy if (_legacy / "accounts").exists() else _support / "Relay"
@@ -127,10 +127,21 @@ for raw in sys.stdin:
127
127
  result = {"type": params["type"]}
128
128
  elif method == "account/rateLimits/read":
129
129
  reset_count = change(lambda d: d.get("reset_credit_count", 1))
130
+ weekly = change(
131
+ lambda d: (
132
+ {
133
+ "usedPercent": d["reset_weekly_used"],
134
+ "resetsAt": d["reset_weekly_at"],
135
+ }
136
+ if "reset_weekly_used" in d
137
+ else None
138
+ )
139
+ )
130
140
  result = {
131
141
  "ordinaryUsageAllowed": True,
132
142
  "rateLimits": {
133
- "primary": {"usedPercent": 20, "resetsAt": int(time.time()) + 3600}
143
+ "primary": {"usedPercent": 20, "resetsAt": int(time.time()) + 3600},
144
+ "secondary": weekly,
134
145
  },
135
146
  "rateLimitResetCredits": {
136
147
  "availableCount": reset_count,
@@ -149,6 +160,8 @@ for raw in sys.stdin:
149
160
  return {"outcome": "noCredit"}
150
161
  keys.append(key)
151
162
  data["reset_credit_count"] = count - 1
163
+ if "reset_weekly_used" in data:
164
+ data["reset_weekly_used"] = 0
152
165
  return {"outcome": "reset"}
153
166
 
154
167
  result = change(redeem)
@@ -27,6 +27,8 @@ class ResetService:
27
27
  self.residual_limit = False
28
28
  self.outcome = None
29
29
  self.reject = False
30
+ self.weekly_used = 100
31
+ self.weekly_at = time.time() + 72 * 3600
30
32
 
31
33
  async def call(self, method, params=None):
32
34
  await asyncio.sleep(0)
@@ -39,7 +41,11 @@ class ResetService:
39
41
  "primary": {
40
42
  "usedPercent": 100 if self.blocked else 0,
41
43
  "resetsAt": time.time() + 3600,
42
- }
44
+ },
45
+ "secondary": {
46
+ "usedPercent": self.weekly_used,
47
+ "resetsAt": self.weekly_at,
48
+ },
43
49
  },
44
50
  "rateLimitResetCredits": {
45
51
  "availableCount": self.count,
@@ -194,11 +200,67 @@ class ResetTests(unittest.IsolatedAsyncioTestCase):
194
200
  with self.assertRaisesRegex(RouterError, "--yes"):
195
201
  await commands.manage(args, self.root, "fake")
196
202
 
203
+ async def test_weekly_guard_blocks_all_mutation_until_zero_and_more_than_48_hours(
204
+ self,
205
+ ):
206
+ for used, hours in ((0, 72), (99.99, 72), (100, 48), (100, 47), (100, -1)):
207
+ with self.subTest(used=used, hours=hours):
208
+ self.service.weekly_used = used
209
+ self.service.weekly_at = time.time() + hours * 3600
210
+ with self.assertRaisesRegex(RouterError, "Reset locked"):
211
+ await resets.consume(self.root, "one", "fake")
212
+ self.assertFalse(self.service.calls)
213
+ self.assertFalse((self.home / "reset-attempt.json").exists())
214
+ self.service.weekly_used = 100
215
+ self.service.weekly_at = time.time() + 72 * 3600
216
+ result = await resets.consume(self.root, "one", "fake")
217
+ self.assertEqual(result["outcome"], "reset")
218
+
219
+ async def test_pending_retry_cannot_bypass_a_changed_weekly_guard(self):
220
+ self.service.lose_reply = True
221
+ with self.assertRaises(RouterError):
222
+ await resets.consume(self.root, "one", "fake")
223
+ self.service.weekly_used = 0
224
+ with self.assertRaisesRegex(RouterError, "fully exhausted"):
225
+ await resets.consume(self.root, "one", "fake")
226
+ self.assertEqual(len(self.service.calls), 1)
227
+ self.assertTrue(resets.pending(self.home))
228
+ self.assertEqual(self.service.count, 1)
229
+
230
+ def test_weekly_guard_exact_boundary_unknown_values_and_unrounded_allowance(self):
231
+ now = 2000000000
232
+
233
+ def usage(used, at):
234
+ return {"rateLimits": {"secondary": {"usedPercent": used, "resetsAt": at}}}
235
+
236
+ self.assertIsNone(resets.weekly_block_reason(usage(100, now + 172801), now))
237
+ for used, at in (
238
+ (100, now + 172800),
239
+ (100, now + 172799),
240
+ (99.999, now + 200000),
241
+ (100, None),
242
+ (None, now + 200000),
243
+ (float("nan"), now + 200000),
244
+ (100, float("inf")),
245
+ ):
246
+ self.assertIsNotNone(resets.weekly_block_reason(usage(used, at), now))
247
+ self.assertIsNotNone(resets.weekly_block_reason({}, now))
248
+
197
249
  async def test_real_jsonl_helper_redeems_and_refreshes_synthetic_account(self):
198
250
  from test_services import fake_executable
199
251
 
200
252
  self.patch.stop()
201
253
  binary = fake_executable(self.root)
254
+ atomic_json(
255
+ self.home / "fake-history.json",
256
+ {
257
+ "threads": {},
258
+ "calls": [],
259
+ "reset_credit_count": 1,
260
+ "reset_weekly_used": 100,
261
+ "reset_weekly_at": time.time() + 72 * 3600,
262
+ },
263
+ )
202
264
  result = await resets.consume(self.root, "one", binary)
203
265
  self.assertEqual(result, {"outcome": "reset", "refreshed": True})
204
266
  history = json.loads((self.home / "fake-history.json").read_text())