pocketshell 0.4.45__tar.gz → 0.4.46__tar.gz

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.
Files changed (86) hide show
  1. {pocketshell-0.4.45 → pocketshell-0.4.46}/PKG-INFO +2 -2
  2. {pocketshell-0.4.45 → pocketshell-0.4.46}/pyproject.toml +18 -14
  3. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/engines.py +6 -1
  4. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/usage.py +46 -85
  5. pocketshell-0.4.46/tests/data/quse-0.0.15-usage.json +226 -0
  6. pocketshell-0.4.46/tests/data/quse-0.0.15-usage.ndjson +6 -0
  7. pocketshell-0.4.46/tests/data/quse-0.0.15-usage.provenance.txt +32 -0
  8. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_daemon.py +30 -18
  9. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_engines.py +32 -0
  10. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_usage.py +321 -139
  11. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_usage_capture.py +26 -22
  12. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_usage_reset.py +1 -1
  13. {pocketshell-0.4.45 → pocketshell-0.4.46}/uv.lock +6 -6
  14. pocketshell-0.4.45/tests/data/quse-0.0.14-usage.json +0 -184
  15. pocketshell-0.4.45/tests/data/quse-0.0.14-usage.provenance.txt +0 -18
  16. {pocketshell-0.4.45 → pocketshell-0.4.46}/.gitignore +0 -0
  17. {pocketshell-0.4.45 → pocketshell-0.4.46}/README.md +0 -0
  18. {pocketshell-0.4.45 → pocketshell-0.4.46}/scheduler/README.md +0 -0
  19. {pocketshell-0.4.45 → pocketshell-0.4.46}/scheduler/pocketshell-usage-capture.service +0 -0
  20. {pocketshell-0.4.45 → pocketshell-0.4.46}/scheduler/pocketshell-usage-capture.timer +0 -0
  21. {pocketshell-0.4.45 → pocketshell-0.4.46}/scripts/sync-readme-exit-codes.py +0 -0
  22. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/__init__.py +0 -0
  23. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/__main__.py +0 -0
  24. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/agent_card_push.py +0 -0
  25. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/agent_log.py +0 -0
  26. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/agents.py +0 -0
  27. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/agents_kind.py +0 -0
  28. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/aplexer.py +0 -0
  29. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/cards.py +0 -0
  30. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/cgroup_agents.py +0 -0
  31. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/cli.py +0 -0
  32. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/daemon.py +0 -0
  33. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/env.py +0 -0
  34. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/github.py +0 -0
  35. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/hooks.py +0 -0
  36. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/jobs.py +0 -0
  37. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/logs.py +0 -0
  38. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/profiles.py +0 -0
  39. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/prune_attachments.py +0 -0
  40. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/push.py +0 -0
  41. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/qr_share.py +0 -0
  42. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/repos.py +0 -0
  43. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/resume.py +0 -0
  44. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/send.py +0 -0
  45. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/serve.py +0 -0
  46. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/session_enum.py +0 -0
  47. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/sessions.py +0 -0
  48. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/tree.py +0 -0
  49. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/usage_capture.py +0 -0
  50. {pocketshell-0.4.45 → pocketshell-0.4.46}/src/pocketshell/usage_reset.py +0 -0
  51. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/__init__.py +0 -0
  52. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/conftest.py +0 -0
  53. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/data/quse-0.0.11-usage.json +0 -0
  54. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/data/quse-0.0.13-usage.json +0 -0
  55. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/data/quse-0.0.9-usage.json +0 -0
  56. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/data/sshd_anchor/Dockerfile.from-unanchored-base +0 -0
  57. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_agent_card_push.py +0 -0
  58. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_agent_log.py +0 -0
  59. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_agents.py +0 -0
  60. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_agents_kind.py +0 -0
  61. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_agents_kind_target.py +0 -0
  62. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_agents_source_target.py +0 -0
  63. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_cards.py +0 -0
  64. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_cards_push_notify.py +0 -0
  65. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_cgroup_agents.py +0 -0
  66. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_cli.py +0 -0
  67. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_daemon_fallback.py +0 -0
  68. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_env.py +0 -0
  69. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_github.py +0 -0
  70. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_hooks.py +0 -0
  71. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_jobs.py +0 -0
  72. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_logs.py +0 -0
  73. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_profiles.py +0 -0
  74. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_prune_attachments.py +0 -0
  75. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_push.py +0 -0
  76. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_qr_share.py +0 -0
  77. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_repos.py +0 -0
  78. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_resume.py +0 -0
  79. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_send.py +0 -0
  80. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_serve.py +0 -0
  81. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_session_enum.py +0 -0
  82. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_sessions.py +0 -0
  83. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_ssh_fixture_penalties.py +0 -0
  84. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_tree.py +0 -0
  85. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_watch_ci.py +0 -0
  86. {pocketshell-0.4.45 → pocketshell-0.4.46}/tests/test_watch_ci_cancelled.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pocketshell
3
- Version: 0.4.45
3
+ Version: 0.4.46
4
4
  Summary: Unified server-side Python utility for the PocketShell Android client.
5
5
  Project-URL: Homepage, https://github.com/alexeygrigorev/pocketshell
6
6
  Project-URL: Issues, https://github.com/alexeygrigorev/pocketshell/issues
@@ -21,7 +21,7 @@ Requires-Python: >=3.11
21
21
  Requires-Dist: click>=8.2.0
22
22
  Requires-Dist: google-auth>=2.0.0
23
23
  Requires-Dist: pyyaml>=6.0
24
- Requires-Dist: quse==0.0.14
24
+ Requires-Dist: quse==0.0.15
25
25
  Requires-Dist: tmuxctl>=0.3.5
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: pytest>=8.4.0; extra == 'dev'
@@ -14,7 +14,7 @@ name = "pocketshell"
14
14
  # scripts/check-pypi-version.sh enforces the stamped value matches the tag;
15
15
  # scripts/check-version-coupling.sh enforces the derivation itself is
16
16
  # single-sourced. See tools/pocketshell/README.md ("Version coupling").
17
- version = "0.4.45"
17
+ version = "0.4.46"
18
18
  description = "Unified server-side Python utility for the PocketShell Android client."
19
19
  readme = "README.md"
20
20
  requires-python = ">=3.11"
@@ -43,16 +43,19 @@ dependencies = [
43
43
  # Usage backend + single source of truth for the unified provider schema
44
44
  # (issue #1318). Pinned exactly: `pocketshell usage --json` expects quse's
45
45
  # provider-keyed `--json` document and fails loudly on schema drift, so the
46
- # version is frozen, not a range. The published 0.0.14 wheel is the
47
- # five-provider legacy shape with `short_term` / `long_term` records. The
48
- # host boundary translates those records into canonical `windows`; it also
49
- # accepts a separate top-level `windows` producer contract for newer or
50
- # custom producers, without treating that shape as PyPI 0.0.14 provenance.
51
- # Canonical-only providers such as OpenCode Go belong to that separate
52
- # contract, not to the published-wheel provider list. 0.0.13 (#2195)
53
- # historically added the `grok` provider; 0.0.11 (#1564) labeled each
54
- # Codex window from its actual `limit_window_seconds`.
55
- "quse==0.0.14",
46
+ # version is frozen, not a range. The published 0.0.15 wheel (upstream
47
+ # a86959e, issue #2293) is the six-provider CANONICAL producer: every
48
+ # record already carries the top-level `windows` map the Android parser
49
+ # reads, so the host boundary only injects the provider key and forwards
50
+ # the record unchanged. #2283's `short_term` / `long_term` translation for
51
+ # the older wheel is hard-cut (D22) a legacy-shaped record now fails
52
+ # loudly. 0.0.15 is also the first published wheel that reports the `go`
53
+ # provider (OpenCode on the Go backend), so the OpenCode Go quota finally
54
+ # reaches the Usage panel; it is a provider of the pinned wheel, not a
55
+ # separate producer contract. 0.0.13 (#2195) historically added the `grok`
56
+ # provider; 0.0.11 (#1564) labeled each Codex window from its actual
57
+ # `limit_window_seconds`.
58
+ "quse==0.0.15",
56
59
  # FCM HTTP v1 push delivery (#690): service-account OAuth2 bearer minting
57
60
  # for `pocketshell push` / the `usage --capture` reset-push send. Imported
58
61
  # lazily and fail-soft — a host without it (or without a Firebase
@@ -128,9 +131,10 @@ exclude = [
128
131
  [tool.uv]
129
132
  # Keep the lockfile's reproducibility cutoff project-local. The host may have
130
133
  # a rolling global `exclude-newer` policy that predates deliberately pinned
131
- # releases (quse 0.0.14 and tmuxctl 0.3.5); this exact value is also the
132
- # `uv.lock` options cutoff and must be used when checking this lock.
133
- exclude-newer = "2026-08-27T22:00:00Z"
134
+ # releases (quse 0.0.15 and tmuxctl 0.3.5); this exact value is also the
135
+ # `uv.lock` options cutoff and must be used when checking this lock. Moved
136
+ # past quse 0.0.15's 2026-08-28T12:09:07Z PyPI upload for issue #2293.
137
+ exclude-newer = "2026-08-28T22:00:00Z"
134
138
 
135
139
  [tool.pytest.ini_options]
136
140
  testpaths = ["tests"]
@@ -280,7 +280,12 @@ def builtin_manifests() -> tuple[EngineManifest, ...]:
280
280
  harness="opencode",
281
281
  label="OpenCode",
282
282
  provider_mark="OpenCode",
283
- usage_provider="opencode",
283
+ # Issue #2293: `usage_provider` names the quse provider whose quota
284
+ # belongs to this engine, and quse has no provider called
285
+ # "opencode" — the OpenCode-on-Go backend is reported as `go`
286
+ # (quse.opencode_go_quota). The old value would have made
287
+ # `pocketshell usage opencode` fail with "Unknown provider".
288
+ usage_provider="go",
284
289
  launch=LaunchSpec(argv=("opencode",)),
285
290
  ),
286
291
  EngineManifest(
@@ -4,17 +4,25 @@ Implementation delegates to the **pinned** `quse` CLI via `subprocess.run`
4
4
  and normalizes its provider-keyed `--json` document into the per-provider
5
5
  NDJSON the Android app consumes. Human output is proxied verbatim.
6
6
 
7
- The pinned PyPI `quse==0.0.14` contract is a five-provider, provider-keyed
8
- object whose records carry legacy `short_term` / `long_term` windows. PocketShell
9
- owns the narrow producer-boundary translation: it injects the provider name and
10
- maps those records to the canonical `windows` map consumed by Android. A
11
- separate canonical producer contract is also accepted for newer or custom host
12
- producers that already emit a top-level `windows` map; that support does not
13
- change the published-wheel provenance or claim OpenCode Go for the pinned
14
- wheel. The Android parser never reads the legacy fields. A schema mismatch
15
- (non-JSON / non-object payload, malformed window container, or missing both
16
- canonical and legacy window fields) raises loudly rather than silently
17
- emptying the panel.
7
+ The pinned PyPI `quse==0.0.15` contract (upstream a86959e, issue #2293) is a
8
+ six-provider, provider-keyed object whose records ALREADY carry the canonical
9
+ top-level `windows` map the Android parser reads `claude`, `codex`,
10
+ `copilot`, `go` (OpenCode on the Go backend), `grok`, `zai`. PocketShell's
11
+ producer boundary is therefore a passthrough: it injects the provider name
12
+ from the object key and forwards the record unchanged. It never re-derives
13
+ quota values, reset times, window labels, or provider details.
14
+
15
+ #2283's `short_term` / `long_term` translation existed only because the
16
+ published wheel lagged quse HEAD; with the pin at 0.0.15 that producer no
17
+ longer exists, so the translation is hard-cut (D22) and a legacy-shaped record
18
+ now fails loudly. A schema mismatch (non-JSON / non-object payload, malformed
19
+ window container, or a missing canonical `windows` map) raises rather than
20
+ silently emptying the panel.
21
+
22
+ `pocketshell usage` keeps NO provider allowlist of its own: the positional
23
+ `provider` argument is forwarded verbatim to the pinned quse, which owns
24
+ provider validation and its error message. That is why "Unknown provider 'go'"
25
+ was only ever fixable by bumping the pin.
18
26
 
19
27
  quse is a hard dependency of pocketshell (see `pyproject.toml`), so its
20
28
  console-script ships in the SAME bin directory as the running interpreter.
@@ -184,90 +192,43 @@ def _canonicalize_quse_record(
184
192
  ) -> dict[str, Any]:
185
193
  """Normalize one provider record at the PocketShell producer boundary.
186
194
 
187
- The pinned PyPI wheel uses the legacy ``short_term`` / ``long_term`` shape;
188
- that translation is deliberately confined to this host boundary. Newer or
189
- custom producers may already provide canonical ``windows`` records, which
190
- are passed through verbatim. Neither branch re-derives provider values,
191
- reset times, or labels. A provider must provide one of those two shapes.
195
+ The pinned ``quse==0.0.15`` wheel IS the canonical producer: every record
196
+ already carries the top-level ``windows`` map the Android parser reads. The
197
+ only thing this boundary does is inject the ``provider`` name that quse
198
+ keeps in the object key quse's ``--json`` document is provider-keyed and
199
+ the app wire is one self-describing record per line. Nothing else is
200
+ touched: windows, percentages, reset times, ``rolling`` flags and
201
+ provider-owned ``details`` are forwarded verbatim.
202
+
203
+ Fail loud (D22 hard cut): a record without a canonical ``windows`` object
204
+ is a schema drift — most plausibly a stale/shadowed quse — and must surface
205
+ as an error, not be silently re-shaped. There is deliberately no
206
+ ``short_term`` / ``long_term`` fallback here any more (#2283 → #2293).
192
207
  """
193
- if "windows" in record:
194
- windows = record["windows"]
195
- if not isinstance(windows, dict):
196
- raise ValueError(
197
- f"quse provider '{provider}' top-level 'windows' is not a JSON object"
198
- )
199
- if "short_term" in record or "long_term" in record:
200
- raise ValueError(
201
- f"quse provider '{provider}' mixes canonical 'windows' with "
202
- "legacy short_term/long_term fields"
203
- )
204
- return {"provider": provider, **record}
205
- if "short_term" not in record and "long_term" not in record:
208
+ if "windows" not in record:
206
209
  raise ValueError(
207
- f"quse provider '{provider}' is missing canonical 'windows' or both "
208
- "legacy window fields 'short_term' and 'long_term'"
210
+ f"quse provider '{provider}' is missing the canonical 'windows' map"
209
211
  )
210
-
211
- windows: dict[str, dict[str, Any]] = {}
212
- for source_key in ("short_term", "long_term"):
213
- if source_key not in record or record[source_key] is None:
214
- continue
215
- source_window = record[source_key]
216
- if not isinstance(source_window, dict):
217
- raise ValueError(
218
- f"quse provider '{provider}' field '{source_key}' is not a JSON object"
219
- )
220
- if "percent_remaining" not in source_window:
221
- raise ValueError(
222
- f"quse provider '{provider}' field '{source_key}' is missing "
223
- "percent_remaining"
224
- )
225
- if "reset_at" not in source_window:
226
- raise ValueError(
227
- f"quse provider '{provider}' field '{source_key}' is missing reset_at"
228
- )
229
- # Legacy quse represents a span that does not apply to a provider with
230
- # a null percentage. Omit it at this compatibility boundary; canonical
231
- # producers may retain that source span and the Android parser omits
232
- # its non-renderable row.
233
- if source_window["percent_remaining"] is None:
234
- continue
235
- label = source_window.get("window") or source_key
236
- if not isinstance(label, str) or not label.strip():
237
- raise ValueError(
238
- f"quse provider '{provider}' field '{source_key}' has an invalid window label"
239
- )
240
- if label in windows:
241
- raise ValueError(
242
- f"quse provider '{provider}' has duplicate window label '{label}'"
243
- )
244
- windows[label] = {
245
- "percent_remaining": source_window["percent_remaining"],
246
- "reset_at": source_window["reset_at"],
247
- }
248
-
249
- flattened: dict[str, Any] = {"provider": provider, **record}
250
- flattened.pop("short_term", None)
251
- flattened.pop("long_term", None)
252
- flattened["windows"] = windows
253
- return flattened
212
+ if not isinstance(record["windows"], dict):
213
+ raise ValueError(
214
+ f"quse provider '{provider}' top-level 'windows' is not a JSON object"
215
+ )
216
+ return {"provider": provider, **record}
254
217
 
255
218
 
256
219
  def normalize_usage_stdout(stdout: str) -> str:
257
220
  """Flatten quse's provider-keyed object into per-provider NDJSON.
258
221
 
259
- Published `quse==0.0.14` records use legacy ``short_term`` /
260
- ``long_term`` fields and receive the one explicit producer-boundary
261
- translation to the app-facing ``windows`` map. A separate canonical
262
- top-level ``windows`` producer record passes through unchanged. This
263
- function never invents percentages or reset times, and Android receives
264
- only the canonical map.
222
+ Published ``quse==0.0.15`` records already carry the canonical top-level
223
+ ``windows`` map, so each record passes through unchanged apart from the
224
+ injected ``provider`` key. This function never invents percentages or
225
+ reset times.
265
226
 
266
227
  Strict / fail-loud: non-JSON stdout, a non-object top-level payload, a
267
- non-object provider value, or malformed producer window fields all raise
268
- ``ValueError`` so a schema mismatch fails visibly instead of silently
269
- emptying the usage panel. Empty/blank stdout passes through untouched (the
270
- caller decides what an empty read means).
228
+ non-object provider value, or a record missing/mis-typing the canonical
229
+ ``windows`` map all raise ``ValueError`` so a schema mismatch fails visibly
230
+ instead of silently emptying the usage panel. Empty/blank stdout passes
231
+ through untouched (the caller decides what an empty read means).
271
232
  """
272
233
  if not stdout.strip():
273
234
  return stdout
@@ -0,0 +1,226 @@
1
+ {
2
+ "claude": {
3
+ "details": {
4
+ "limit_reached": false,
5
+ "subscription": null,
6
+ "windows": {
7
+ "five_hour": {
8
+ "reset_at": "2026-08-28T16:20:00Z",
9
+ "used_percent": 6.0
10
+ },
11
+ "seven_day": {
12
+ "reset_at": "2026-09-03T15:00:00Z",
13
+ "used_percent": 10.0
14
+ }
15
+ }
16
+ },
17
+ "error": null,
18
+ "status": "ok",
19
+ "windows": {
20
+ "5h": {
21
+ "percent_remaining": 94.0,
22
+ "reset_at": "2026-08-28T16:20:00Z",
23
+ "rolling": false
24
+ },
25
+ "7d": {
26
+ "percent_remaining": 90.0,
27
+ "reset_at": "2026-09-03T15:00:00Z"
28
+ },
29
+ "monthly": {
30
+ "percent_remaining": null,
31
+ "reset_at": null
32
+ }
33
+ }
34
+ },
35
+ "codex": {
36
+ "details": {
37
+ "limit_reached": false,
38
+ "reset_credits": [
39
+ {
40
+ "expires_at": "2026-09-21T00:13:17Z",
41
+ "status": "available",
42
+ "title": "Full reset"
43
+ }
44
+ ],
45
+ "reset_credits_available": 1,
46
+ "reset_credits_error": null,
47
+ "windows": {
48
+ "primary_window": {
49
+ "limit_window_seconds": 604800,
50
+ "present": true,
51
+ "reset_at": "2026-09-03T16:26:48Z",
52
+ "used_percent": 13.0
53
+ },
54
+ "secondary_window": {
55
+ "limit_window_seconds": null,
56
+ "present": false,
57
+ "reset_at": null,
58
+ "used_percent": 0.0
59
+ }
60
+ }
61
+ },
62
+ "error": null,
63
+ "status": "ok",
64
+ "windows": {
65
+ "5h": {
66
+ "percent_remaining": null,
67
+ "reset_at": null,
68
+ "rolling": false
69
+ },
70
+ "7d": {
71
+ "percent_remaining": 87.0,
72
+ "reset_at": "2026-09-03T16:26:48Z"
73
+ },
74
+ "monthly": {
75
+ "percent_remaining": null,
76
+ "reset_at": null
77
+ }
78
+ }
79
+ },
80
+ "copilot": {
81
+ "details": {
82
+ "limit_reached": true,
83
+ "premium_entitlement": 1500,
84
+ "premium_percent_remaining": 0.0,
85
+ "premium_remaining": -1
86
+ },
87
+ "error": null,
88
+ "status": "ok",
89
+ "windows": {
90
+ "5h": {
91
+ "percent_remaining": null,
92
+ "reset_at": null,
93
+ "rolling": false
94
+ },
95
+ "7d": {
96
+ "percent_remaining": null,
97
+ "reset_at": null
98
+ },
99
+ "monthly": {
100
+ "percent_remaining": 0.0,
101
+ "reset_at": "2026-09-01T00:00:00Z"
102
+ }
103
+ }
104
+ },
105
+ "go": {
106
+ "details": {
107
+ "limit_reached": false,
108
+ "max_used_percent": 64.0
109
+ },
110
+ "error": null,
111
+ "status": "ok",
112
+ "windows": {
113
+ "5h": {
114
+ "percent_remaining": 36.0,
115
+ "reset_at": "2026-08-28T13:36:26Z",
116
+ "rolling": true
117
+ },
118
+ "7d": {
119
+ "percent_remaining": 74.0,
120
+ "reset_at": "2026-08-31T00:00:00Z"
121
+ },
122
+ "monthly": {
123
+ "percent_remaining": 86.0,
124
+ "reset_at": "2026-09-22T06:20:28Z"
125
+ }
126
+ }
127
+ },
128
+ "grok": {
129
+ "details": {
130
+ "has_grok_code_access": true,
131
+ "is_unified_billing_user": true,
132
+ "limit_reached": true,
133
+ "on_demand_cap": 0.0,
134
+ "on_demand_used": 0.0,
135
+ "prepaid_balance": 0.0,
136
+ "product_usage": [
137
+ {
138
+ "product": "GrokBuild",
139
+ "usage_percent": 100.0
140
+ }
141
+ ],
142
+ "resets": [
143
+ {
144
+ "expires_at": "2026-09-12T18:49:00Z",
145
+ "token_id": "restok_vpYDqo"
146
+ }
147
+ ],
148
+ "resets_available": 1,
149
+ "resets_error": null,
150
+ "windows": {
151
+ "monthly": {
152
+ "limit": null,
153
+ "present": false,
154
+ "reset_at": null,
155
+ "used": null,
156
+ "used_percent": null
157
+ },
158
+ "weekly": {
159
+ "limit": 0.0,
160
+ "present": true,
161
+ "reset_at": "2026-09-01T00:08:17Z",
162
+ "used": 0.0,
163
+ "used_percent": 100.0
164
+ }
165
+ }
166
+ },
167
+ "error": null,
168
+ "status": "ok",
169
+ "windows": {
170
+ "5h": {
171
+ "percent_remaining": null,
172
+ "reset_at": null,
173
+ "rolling": false
174
+ },
175
+ "7d": {
176
+ "percent_remaining": 0.0,
177
+ "reset_at": "2026-09-01T00:08:17Z"
178
+ },
179
+ "monthly": {
180
+ "percent_remaining": null,
181
+ "reset_at": null
182
+ }
183
+ }
184
+ },
185
+ "zai": {
186
+ "details": {
187
+ "limit_reached": false,
188
+ "max_used_percent": 45.0,
189
+ "windows": {
190
+ "five_hour": {
191
+ "limit": null,
192
+ "present": true,
193
+ "remaining": null,
194
+ "reset_at": null,
195
+ "used_percent": 1.0,
196
+ "window_hours": 5
197
+ },
198
+ "weekly": {
199
+ "limit": null,
200
+ "present": true,
201
+ "remaining": null,
202
+ "reset_at": "2026-09-03T14:04:58Z",
203
+ "used_percent": 45.0,
204
+ "window_hours": null
205
+ }
206
+ }
207
+ },
208
+ "error": null,
209
+ "status": "ok",
210
+ "windows": {
211
+ "5h": {
212
+ "percent_remaining": 99.0,
213
+ "reset_at": null,
214
+ "rolling": true
215
+ },
216
+ "7d": {
217
+ "percent_remaining": 55.0,
218
+ "reset_at": "2026-09-03T14:04:58Z"
219
+ },
220
+ "monthly": {
221
+ "percent_remaining": null,
222
+ "reset_at": null
223
+ }
224
+ }
225
+ }
226
+ }
@@ -0,0 +1,6 @@
1
+ {"details": {"limit_reached": false, "subscription": null, "windows": {"five_hour": {"reset_at": "2026-08-28T16:20:00Z", "used_percent": 6.0}, "seven_day": {"reset_at": "2026-09-03T15:00:00Z", "used_percent": 10.0}}}, "error": null, "provider": "claude", "status": "ok", "windows": {"5h": {"percent_remaining": 94.0, "reset_at": "2026-08-28T16:20:00Z", "rolling": false}, "7d": {"percent_remaining": 90.0, "reset_at": "2026-09-03T15:00:00Z"}, "monthly": {"percent_remaining": null, "reset_at": null}}}
2
+ {"details": {"limit_reached": false, "reset_credits": [{"expires_at": "2026-09-21T00:13:17Z", "status": "available", "title": "Full reset"}], "reset_credits_available": 1, "reset_credits_error": null, "windows": {"primary_window": {"limit_window_seconds": 604800, "present": true, "reset_at": "2026-09-03T16:26:48Z", "used_percent": 13.0}, "secondary_window": {"limit_window_seconds": null, "present": false, "reset_at": null, "used_percent": 0.0}}}, "error": null, "provider": "codex", "status": "ok", "windows": {"5h": {"percent_remaining": null, "reset_at": null, "rolling": false}, "7d": {"percent_remaining": 87.0, "reset_at": "2026-09-03T16:26:48Z"}, "monthly": {"percent_remaining": null, "reset_at": null}}}
3
+ {"details": {"limit_reached": true, "premium_entitlement": 1500, "premium_percent_remaining": 0.0, "premium_remaining": -1}, "error": null, "provider": "copilot", "status": "ok", "windows": {"5h": {"percent_remaining": null, "reset_at": null, "rolling": false}, "7d": {"percent_remaining": null, "reset_at": null}, "monthly": {"percent_remaining": 0.0, "reset_at": "2026-09-01T00:00:00Z"}}}
4
+ {"details": {"limit_reached": false, "max_used_percent": 64.0}, "error": null, "provider": "go", "status": "ok", "windows": {"5h": {"percent_remaining": 36.0, "reset_at": "2026-08-28T13:36:26Z", "rolling": true}, "7d": {"percent_remaining": 74.0, "reset_at": "2026-08-31T00:00:00Z"}, "monthly": {"percent_remaining": 86.0, "reset_at": "2026-09-22T06:20:28Z"}}}
5
+ {"details": {"has_grok_code_access": true, "is_unified_billing_user": true, "limit_reached": true, "on_demand_cap": 0.0, "on_demand_used": 0.0, "prepaid_balance": 0.0, "product_usage": [{"product": "GrokBuild", "usage_percent": 100.0}], "resets": [{"expires_at": "2026-09-12T18:49:00Z", "token_id": "restok_vpYDqo"}], "resets_available": 1, "resets_error": null, "windows": {"monthly": {"limit": null, "present": false, "reset_at": null, "used": null, "used_percent": null}, "weekly": {"limit": 0.0, "present": true, "reset_at": "2026-09-01T00:08:17Z", "used": 0.0, "used_percent": 100.0}}}, "error": null, "provider": "grok", "status": "ok", "windows": {"5h": {"percent_remaining": null, "reset_at": null, "rolling": false}, "7d": {"percent_remaining": 0.0, "reset_at": "2026-09-01T00:08:17Z"}, "monthly": {"percent_remaining": null, "reset_at": null}}}
6
+ {"details": {"limit_reached": false, "max_used_percent": 45.0, "windows": {"five_hour": {"limit": null, "present": true, "remaining": null, "reset_at": null, "used_percent": 1.0, "window_hours": 5}, "weekly": {"limit": null, "present": true, "remaining": null, "reset_at": "2026-09-03T14:04:58Z", "used_percent": 45.0, "window_hours": null}}}, "error": null, "provider": "zai", "status": "ok", "windows": {"5h": {"percent_remaining": 99.0, "reset_at": null, "rolling": true}, "7d": {"percent_remaining": 55.0, "reset_at": "2026-09-03T14:04:58Z"}, "monthly": {"percent_remaining": null, "reset_at": null}}}
@@ -0,0 +1,32 @@
1
+ Fixture provenance for quse==0.0.15 (issue #2293)
2
+
3
+ Source package: the PyPI wheel quse-0.0.15-py3-none-any.whl
4
+ PyPI upload: 2026-08-28T12:09:07Z
5
+ Wheel SHA-256: a9138ed2e4f12a01812103332fe27b94de9f96a2413212c59558052481815793
6
+ Upstream commit: a86959edeef1c78a2a0084d3488fca487135b1b0
7
+ ("Add unified Go and quota output" — the unified `windows` map + the `go`
8
+ provider). 0.0.14 predates it; see issue #2274 / #2283 for the interim
9
+ translate-at-the-boundary design this release retires.
10
+
11
+ Capture command (using the published wheel, not a checkout):
12
+ PYTHONPATH=<unpacked quse-0.0.15 wheel> <python> -c \
13
+ 'from quse.cli import main; raise SystemExit(main(["--json"]))'
14
+ Captured: 2026-08-28 on the maintainer's dev box (real live provider data).
15
+
16
+ Observed published contract:
17
+ providers: claude, codex, copilot, go, grok, zai
18
+ record fields: details, error, status, windows
19
+ every record carries the canonical top-level `windows` map keyed by
20
+ quse.usage.CANONICAL_WINDOWS = ("5h", "7d", "monthly"); the `5h` entry also
21
+ carries `rolling: bool`. A span that does not apply to a provider is present
22
+ with `percent_remaining: null` rather than omitted.
23
+ no `short_term` / `long_term` fields on any record
24
+
25
+ Because 0.0.15 IS the canonical producer, `pocketshell usage --json` performs no
26
+ schema translation any more: `_canonicalize_quse_record` injects the provider
27
+ key and forwards the record verbatim. The sibling file
28
+ `quse-0.0.15-usage.ndjson` is the EXACT output of
29
+ `normalize_usage_stdout(quse-0.0.15-usage.json)` and is byte-compared against
30
+ the producer in tests/test_usage.py, and mirrored into
31
+ shared/core-usage/src/test/resources/ so the Android parser test consumes the
32
+ real producer bytes rather than a Kotlin re-implementation.
@@ -285,9 +285,12 @@ def test_usage_fetch_round_trip(
285
285
  provider-keyed output into per-provider NDJSON (issue #1318)."""
286
286
  fake_bin = tmp_path / "bin"
287
287
  python_exe = _fake_python_bin(fake_bin)
288
- # Published quse 0.0.14 emits a provider-keyed short/long object; the
289
- # daemon applies the producer-boundary canonical windows translation.
290
- payload = '{"codex": {"status": "ok", "short_term": {"percent_remaining": 77.0, "reset_at": null, "window": "5h"}}}\n'
288
+ # Published quse 0.0.15 emits a provider-keyed CANONICAL object; the
289
+ # daemon injects the provider key and forwards the record unchanged.
290
+ payload = (
291
+ '{"codex": {"status": "ok", '
292
+ '"windows": {"5h": {"percent_remaining": 77.0, "reset_at": null, "rolling": false}}}}\n'
293
+ )
291
294
  _write_fake_quse(fake_bin, payload=payload)
292
295
 
293
296
  proc = _spawn_daemon(sandbox_socket, python_exe=python_exe)
@@ -323,12 +326,15 @@ def test_two_concurrent_clients_cache_hit_is_faster(
323
326
  python_exe = _fake_python_bin(fake_bin)
324
327
  payload = (
325
328
  '{"codex": {"status": "ok", '
326
- '"short_term": {"percent_remaining": null, "reset_at": null, "window": null}, '
327
- '"long_term": {"percent_remaining": null, "reset_at": null, "window": null}}}\n'
329
+ '"windows": {"5h": {"percent_remaining": null, "reset_at": null, "rolling": false}, '
330
+ '"7d": {"percent_remaining": null, "reset_at": null}, '
331
+ '"monthly": {"percent_remaining": null, "reset_at": null}}}}\n'
328
332
  )
329
333
  # The daemon flattens the provider-keyed payload into NDJSON before
330
334
  # caching; the cold and warm responses both carry this flattened form.
331
- flattened = '{"provider": "codex", "status": "ok", "windows": {}}\n'
335
+ flattened = (
336
+ '{"provider": "codex", "status": "ok", "windows": {"5h": {"percent_remaining": null, "reset_at": null, "rolling": false}, "7d": {"percent_remaining": null, "reset_at": null}, "monthly": {"percent_remaining": null, "reset_at": null}}}\n'
337
+ )
332
338
  _write_fake_quse(fake_bin, payload=payload, sleep_secs=0.3)
333
339
 
334
340
  proc = _spawn_daemon(sandbox_socket, python_exe=python_exe)
@@ -400,8 +406,9 @@ def test_no_cache_param_bypasses_cache(
400
406
  "n=$((n+1))\n"
401
407
  f'echo "$n" > {counter_file}\n'
402
408
  'printf \'{"x": {"status": "ok", "call": %s, '
403
- '"short_term": {"percent_remaining": null, "reset_at": null, "window": null}, '
404
- '"long_term": {"percent_remaining": null, "reset_at": null, "window": null}}}\\n\' "$n"\n'
409
+ '"windows": {"5h": {"percent_remaining": null, "reset_at": null, "rolling": false}, '
410
+ '"7d": {"percent_remaining": null, "reset_at": null}, '
411
+ '"monthly": {"percent_remaining": null, "reset_at": null}}}}\\n\' "$n"\n'
405
412
  )
406
413
  script.chmod(0o755)
407
414
 
@@ -519,13 +526,15 @@ def test_no_daemon_flag_skips_daemon_probe(
519
526
  # path answered (issue #1318: provider-keyed payloads).
520
527
  daemon_payload = (
521
528
  '{"fromdaemon": {"status": "ok", '
522
- '"short_term": {"percent_remaining": null, "reset_at": null, "window": null}, '
523
- '"long_term": {"percent_remaining": null, "reset_at": null, "window": null}}}\n'
529
+ '"windows": {"5h": {"percent_remaining": null, "reset_at": null, "rolling": false}, '
530
+ '"7d": {"percent_remaining": null, "reset_at": null}, '
531
+ '"monthly": {"percent_remaining": null, "reset_at": null}}}}\n'
524
532
  )
525
533
  subprocess_payload = (
526
534
  '{"fromsubprocess": {"status": "ok", '
527
- '"short_term": {"percent_remaining": null, "reset_at": null, "window": null}, '
528
- '"long_term": {"percent_remaining": null, "reset_at": null, "window": null}}}\n'
535
+ '"windows": {"5h": {"percent_remaining": null, "reset_at": null, "rolling": false}, '
536
+ '"7d": {"percent_remaining": null, "reset_at": null}, '
537
+ '"monthly": {"percent_remaining": null, "reset_at": null}}}}\n'
529
538
  )
530
539
  _write_fake_quse(fake_bin, payload=subprocess_payload)
531
540
 
@@ -569,13 +578,15 @@ def test_usage_uses_daemon_by_default(
569
578
  fake_bin.mkdir()
570
579
  daemon_payload = (
571
580
  '{"fromdaemon": {"status": "ok", '
572
- '"short_term": {"percent_remaining": null, "reset_at": null, "window": null}, '
573
- '"long_term": {"percent_remaining": null, "reset_at": null, "window": null}}}\n'
581
+ '"windows": {"5h": {"percent_remaining": null, "reset_at": null, "rolling": false}, '
582
+ '"7d": {"percent_remaining": null, "reset_at": null}, '
583
+ '"monthly": {"percent_remaining": null, "reset_at": null}}}}\n'
574
584
  )
575
585
  subprocess_payload = (
576
586
  '{"fromsubprocess": {"status": "ok", '
577
- '"short_term": {"percent_remaining": null, "reset_at": null, "window": null}, '
578
- '"long_term": {"percent_remaining": null, "reset_at": null, "window": null}}}\n'
587
+ '"windows": {"5h": {"percent_remaining": null, "reset_at": null, "rolling": false}, '
588
+ '"7d": {"percent_remaining": null, "reset_at": null}, '
589
+ '"monthly": {"percent_remaining": null, "reset_at": null}}}}\n'
579
590
  )
580
591
  _write_fake_quse(fake_bin, payload=subprocess_payload)
581
592
 
@@ -612,8 +623,9 @@ def test_usage_falls_through_when_daemon_absent(
612
623
  fake_bin.mkdir()
613
624
  payload = (
614
625
  '{"fromsubprocess": {"status": "ok", '
615
- '"short_term": {"percent_remaining": null, "reset_at": null, "window": null}, '
616
- '"long_term": {"percent_remaining": null, "reset_at": null, "window": null}}}\n'
626
+ '"windows": {"5h": {"percent_remaining": null, "reset_at": null, "rolling": false}, '
627
+ '"7d": {"percent_remaining": null, "reset_at": null}, '
628
+ '"monthly": {"percent_remaining": null, "reset_at": null}}}}\n'
617
629
  )
618
630
  _write_fake_quse(fake_bin, payload=payload)
619
631
  monkeypatch.setattr(