cdx-manager 0.25.8 → 0.25.9

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
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.25.8-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.25.9-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
10
10
 
@@ -89,7 +89,7 @@ Recovery does not require `--failover`, which still controls account quota exhau
89
89
 
90
90
  Contributions are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md). CDX Manager is [MIT licensed](LICENSE).
91
91
 
92
- ### Run Codex on an SSH worker
92
+ ### Run Codex or Ollama on an SSH worker
93
93
 
94
94
  ```sh
95
95
  cdx run work --remote agent@worker --cwd /srv/project --prompt 'Run the checks' --json
@@ -106,12 +106,12 @@ not synchronized. Use `--project` to explicitly attribute a remote checkout to a
106
106
  project. Remote runs do not retry or fail over automatically, including when
107
107
  `--failover` is supplied.
108
108
 
109
- Requirements: a POSIX controller with `ssh` and Codex on PATH, and a Linux worker
109
+ For Codex: a POSIX controller with `ssh` and Codex on PATH, and a Linux worker
110
110
  with Python 3, memory-backed `/dev/shm`, and exactly the same `codex --version`.
111
111
  Configure SSH keys, host trust and aliases in advance; the connection uses batch
112
112
  mode and bounded connection/keepalive timeouts. Provider refusal, unreachable
113
- workers and version mismatches fail before sending credentials. Only Codex OAuth
114
- profiles are supported. Remote continuation is refused because the temporary home
113
+ workers and version mismatches fail before sending credentials. Codex requires an OAuth
114
+ profile. Remote continuation is refused because the temporary home
115
115
  does not preserve conversation rollouts. Codex's native permission sandbox still
116
116
  applies to ordinary remote runs.
117
117
 
@@ -123,7 +123,32 @@ Only a short, double-quoted Python bootstrap is sent in the SSH command line;
123
123
  the worker and shared sandbox source are framed on stdin before the payload.
124
124
  Neither source size nor prompt size consumes cmd.exe's 8191-character command limit.
125
125
 
126
- `--remote` supports `--isolate-workspace` using the same local isolation mechanism:
126
+ Remote Ollama profiles also support headless text generation:
127
+
128
+ ```bash
129
+ cdx run local --remote worker --cwd /srv/project --model qwen3:8b --prompt-file question.txt --isolate-workspace --json
130
+ ```
131
+
132
+ Here `local` is an existing Ollama profile. The named model must already be
133
+ installed on the worker's unauthenticated `127.0.0.1:11434` Ollama API; CDX
134
+ never downloads a missing model and rejects cloud-backed models. The prompt
135
+ file is read on the launching host and sent as content over SSH. This path
136
+ uses `/api/show` and `/api/chat` with `stream: false`, without an Ollama CLI or
137
+ Codex installation. Results identify `execution_mode: "text_generation"` and
138
+ include generated text in `result`, plus `prompt_eval_count` / `eval_count`
139
+ and normalized input/output token counts in `usage`.
140
+
141
+ Ollama is not an autonomous coding agent and does not edit the repository.
142
+ With `--isolate-workspace`, the checkout is read-only and the client has its
143
+ own network namespace without network access, including when `--network-access`
144
+ is supplied. A private Unix socket connects it to a supervisor-owned relay
145
+ with the fixed loopback destination and only the selected model's capability
146
+ and non-streaming inference calls allowed. Pull, delete, push, arbitrary URLs,
147
+ proxying and redirects are refused. No `~/.ollama` directory or model files
148
+ are mounted. The relay is removed on completion or cancellation. Remote
149
+ continuation remains unsupported; an explicit model or profile model is required.
150
+
151
+ Codex `--remote` supports `--isolate-workspace` using the same local isolation mechanism:
127
152
  bubblewrap, pasta (passt), nftables, Python, the checkout/toolchain allowlist,
128
153
  and an actual namespace/network setup using a dummy credential. Missing dependencies,
129
154
  namespace denial or invalid checkouts fail before sending real credentials, without
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.25.8
1
+ 0.25.9
@@ -0,0 +1,22 @@
1
+ # CDX Manager 0.25.9
2
+
3
+ Release date: 2026-11-03
4
+
5
+ This release expands isolated remote provider support and improves Antigravity
6
+ quota, usage reporting, and update feedback.
7
+
8
+ ## Remote runs
9
+
10
+ - Integrate worker-owned Antigravity headless runs with mandatory isolation, a volatile home, restricted inference networking, and redacted output. Keep worker credentials on the worker and preserve local profile authentication. Live worker acceptance measurements remain required before claiming production support. (#98)
11
+ - Enable isolated remote Claude runs with a dedicated OAuth setup token stored on the controller and transported through stdin. Use a fresh memory-backed home per attempt, restrict inference traffic by default, and retain usage reporting and output redaction. (#95)
12
+ - Add remote Ollama text generation with an explicitly selected installed worker model through a restricted local relay. Limit requests to model inspection and non-streaming chat; keep the checkout read-only and report generated text and token usage. This mode does not edit repositories or act as an autonomous coding agent. (#94)
13
+
14
+ ## Antigravity status and updates
15
+
16
+ - Show Antigravity quota, reset times, and reading age from a local statusLine integration that preserves existing commands. Record native headless token counters, and identify stale or expired quota readings. (#96)
17
+ - Keep successful `agy update` commands successful when no newer version can be verified. Report an `update_unverified` warning while retaining unknown latest-version status; non-zero updater exits still fail. (#93)
18
+
19
+ ## Maintenance
20
+
21
+ - Record tray companion checksums for v0.25.8. (`d0c8d33b671234cf648da2b21b7bb0bfb0ce302e`)
22
+ - Record release archive checksums for v0.25.8. (`20fb80c7d115500c8da5be433526e195063258d9`)
@@ -1,5 +1,24 @@
1
1
  # Unreleased
2
2
 
3
+ ## Antigravity quota and run usage
4
+
5
+ - Install a local Antigravity statusLine command in new profiles and on interactive launch, merging settings and relaying the original stdin to any existing command so its output is preserved. Store only the reading time, plan tier and allowlisted quota fields in an atomic mode-600 profile file; never access Google internal endpoints or antigravity-oauth-token.
6
+ - Show Gemini 5-hour and weekly remaining quota, reset times and reading age in status. Include all bucket names, including third-party buckets, in JSON. Mark readings older than 24 hours stale and suppress availability after a bucket's reset has passed.
7
+ - Launch headless Antigravity with `agy -p --output-format json` and record its input, output, thinking, cache-read and reported total counters. Preserve the native total without assuming an undocumented cache or thinking subset relationship. Interactive quota is not per-run token usage.
8
+ - Cover synthetic native payloads, missing/unknown buckets, stale and expired readings, settings merges, existing command output, private file permissions and headless observation.
9
+
10
+ ## Worker-owned Antigravity headless runs
11
+
12
+ - Allow Antigravity profiles in remote runs with mandatory bubblewrap, explicit `agy -p --output-format json`, an owner-only worker token mounted read-only, and a volatile HOME. Never transport worker credentials to the controller; redact worker output before SSH forwarding. Hold a worker token lock across each attempt and disable automatic CLI updates.
13
+ - Restrict inference to a conservative Google endpoint allowlist plus public DNS, including when tool network access is requested; retain private-range denial. The candidate list and read-only token policy require the live measurements documented in the mission fragment before claiming production acceptance.
14
+ - Fail on absent/unsafe worker login, expired sessions, missing/non-success JSON status and refused actions. Normalize JSON usage through `run_usage` and preserve existing run artifacts/history. Cover fake worker success, refusal, expiry, secret redaction, locking, cleanup, local profile authentication and mount/network construction.
15
+
16
+ ## Remote Claude headless runs
17
+
18
+ - Add `cdx auth claude-token <profile>` to store a dedicated `claude setup-token` value from stdin in a controller-only mode-600 profile file. Missing or unsafe tokens stop remote runs with this command as the remedy; worker login is unnecessary.
19
+ - Allow remote Claude JSON headless execution with mandatory bubblewrap isolation, a fresh memory-backed HOME per attempt and no local profile mounts. Carry the token through SSH stdin and a sandbox stdin exec shim, exposing it only in Claude's environment, never worker credential files, argv or persisted output. Keep subprocess environment scrubbing, remove competing authentication/cloud/proxy sources, disable non-essential traffic and refuse `--bare`.
20
+ - Allow inference HTTPS to resolved `api.anthropic.com` addresses and DNS by default; retain public tool-network access only when requested. Cover fake-worker usage parsing, output redaction, absent/unsafe token files, stdin storage, isolation construction and volatile cleanup after success, failure and timeout.
21
+
3
22
  ## Isolated mission Git and RTK
4
23
 
5
24
  - Give local and remote isolated linked worktrees a read-only Git view with their own HEAD/index and shared objects/refs, without traversing private host parents or exposing repository/global configuration, hooks, SSH keys or Git push credentials. Preserve supervisor-owned commits and pushes.
@@ -8,10 +27,11 @@
8
27
 
9
28
  ## Truthful workstation updates
10
29
 
30
+ - Keep a zero-exit `agy update` successful when no expected newer CLI version is known, even if its installed version does not advance and its output cannot confirm currency. Preserve `status: unknown` and `latest_version: null`, expose an `update_unverified` warning in JSON and text, and continue failing non-zero updater exits. No additional version source is assumed.
11
31
  - Stop querying the editor release feed for the Antigravity CLI: its versions do not match `agy`. Keep `latest_version: null` and `status: unknown`, while still running the native `agy update` with `--yes` or confirmation. No read-only latest-version check is documented by `agy update --help`.
12
32
  - Accept a successful native updater's `You are already on the latest version.` message when the installed version is unchanged, or verify a version increase against the published version when known. Preserve updater failures and unknown inventory status. Cover the real Antigravity output line, version increases, failures, and JSON/text uncertainty.
13
33
 
14
- - Detect advertised native updater subcommands in both stdout and stderr; use Antigravity's known `agy update` command directly. Verify the installed CLI version after updating, fail unverified updates unless an unchanged version is explicitly confirmed current by the updater, and expose before/after versions without hiding major upgrades.
34
+ - Detect advertised native updater subcommands in both stdout and stderr; use Antigravity's known `agy update` command directly. Verify the installed CLI version after updating, retain strict verification for providers with a known expected release, and expose before/after versions without hiding major upgrades.
15
35
  - Fall back from empty or failed Ollama vendor responses (including Linux HTTP 204) to the latest stable `ollama/ollama` GitHub release. Keep Linux updates manual with `update_available`, a null command, and a service-safety reason; never replace a potentially active binary or restart models automatically.
16
36
 
17
37
  - Verify latest versions from npm, RTK GitHub releases, and Ollama vendor update endpoints with short read-only probes. Report `unknown` on unavailable evidence; reserve `up_to_date` for identical known versions, including Ponytail revisions.
@@ -57,3 +77,9 @@
57
77
  - Keep snapshots independent of controller refresh during long missions, prohibit authentication writeback, enforce their original expiry, and remove temporary storage after success, failure and stop. Non-isolated local authentication remains unchanged.
58
78
  - Reject rotating credentials at the sandbox boundary. Test snapshot contents, permissions, cleanup, refresh lock ownership, concurrent controller refresh, remote isolation through fake SSH and missing dependency refusal.
59
79
  - The separately maintained remote-ops `scripts/workspace_sandbox.py` requires the same credential guard and run-private lifecycle integration; it is not shipped by this repository.
80
+
81
+ ## Remote Ollama text generation
82
+
83
+ - Allow Ollama profiles in `cdx run --remote` with an explicitly selected, already installed local worker model and no authentication or Ollama/Codex CLI requirement. Read prompts on the launching host and use the HTTP capability/chat APIs with `stream: false`; retain generated text and prompt/evaluation token counters in run results and usage.
84
+ - Add a worker-supervised private Unix socket relay restricted to the fixed `127.0.0.1:11434` destination, selected model, `/api/show` and non-streaming `/api/chat`. Refuse model management, alternate destinations and cloud-backed models. Isolated clients have no network access and mount neither Ollama profiles nor model files; their checkout is read-only.
85
+ - State in CLI help and results that this is text generation, not an autonomous coding agent, with no repository edits. Cover success, usage, controller prompt content, missing models, forbidden relay calls, unreachable servers and sandbox relay construction with a fake HTTP API.
@@ -771,6 +771,17 @@
771
771
  "x86_64-pc-windows-msvc": "be2e6ff8607351019447c6fbf0622254c2f8d8db81581007af71b74932e37f50",
772
772
  "x86_64-unknown-linux-musl": "32de07569929ade136cf75ed8f0716e0f3b96a9c53c6632c628e13782d223518"
773
773
  }
774
+ },
775
+ "v0.25.8": {
776
+ "github_tarball_sha256": "4a3e3eeb654a4590ea033e6d99337a57b9f255161ee2b9c9347e8def0d257868",
777
+ "github_zip_sha256": "ad2c7e8d1528f36f4be66320970522385ba3c0a473ab893475b174609ec05630",
778
+ "tray_assets": {
779
+ "aarch64-apple-darwin": "46e4e9ce2ce54d974dd3b77364fc97595b4d4388787c2e7486245374d342ff87",
780
+ "aarch64-unknown-linux-musl": "74afc63053dc3f6ca3fae010d8afc64c917cd37e71cc005dd8b9d3e9383b99e6",
781
+ "x86_64-apple-darwin": "990b305bf32d24e0126cb6f3fe42cd9858a6cd5dc460f41d0fb4419516324a40",
782
+ "x86_64-pc-windows-msvc": "9577f9dff7d457094276376f262904dad8c9aff95ed2ca87b834c9c900fb5493",
783
+ "x86_64-unknown-linux-musl": "db7e322037041c2a898043f0c7a691c22cab6a794aab5b2439a06ac91e92fa39"
784
+ }
774
785
  }
775
786
  }
776
787
  }
package/docs/cli.md CHANGED
@@ -19,6 +19,7 @@
19
19
  | `cdx label <name> --clear [--json]` | Clear a session label |
20
20
  | `cdx login <name> [--json]` | Re-authenticate a session (logout + login) |
21
21
  | `cdx logout <name> [--json]` | Log out of a session |
22
+ | `cdx auth claude-token <profile> [--json]` | Store a dedicated remote Claude setup token from stdin in a private controller profile file |
22
23
  | `cdx auth refresh <name\|all> [--json]` | Make a locked, non-generation Codex auth probe; reports `login_required` when interactive login is needed |
23
24
  | `cdx disable <name> [--json]` | Disable a session without deleting it; disabled sessions stay visible and cannot launch |
24
25
  | `cdx enable <name> [--json]` | Re-enable a disabled session |
@@ -240,6 +241,74 @@ cdx run \
240
241
 
241
242
  The result includes `launcher: "cdx"`, `run_id`, selected `session`, `provider`, `exit_code`, `duration_seconds`, absolute `transcript_path`, `stdout_path`, `stderr_path`, and normalized usage token fields. Codex headless runs use `codex exec --json`; Claude headless runs use `claude --print --output-format json`. Token counts are `null` when the provider does not expose a supported JSON or JSONL usage shape.
242
243
 
244
+ Remote Claude profiles require a dedicated long-lived token produced by `claude setup-token`
245
+ on the launching host. Supply only that token on standard input to
246
+ `cdx auth claude-token <profile>` (never put it in command arguments). This stores
247
+ `.cdx-remote-claude-token` with mode `0600` in the controller profile; ordinary
248
+ interactive Claude credentials and ambient API keys are not a fallback. A missing
249
+ or unsafe file stops before SSH and names this command.
250
+
251
+ Run `cdx run <profile> --remote worker --cwd /srv/project --prompt-file task.md --json`.
252
+ Claude always uses bubblewrap isolation, even without `--isolate-workspace`, and
253
+ requires matching Claude CLI versions on the controller and Linux worker, Python 3,
254
+ bubblewrap, pasta, nftables, working namespaces and memory-backed `/dev/shm`.
255
+ No worker login or profile mount is needed. Each attempt sends the token over SSH
256
+ stdin, then a private pipe through the sandbox to an exec shim that sets it only
257
+ in Claude's environment. It is never a worker credential file or command argument;
258
+ provider output is redacted before persistence. Fresh volatile HOME storage holds
259
+ `.claude`, `.claude.json`, caches and traces and is removed on exit, failure or stop.
260
+ `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1` and
261
+ `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` remain set; API keys, proxy tokens,
262
+ proxy URLs and cloud-provider variables are excluded from the sandbox environment.
263
+ `--bare` is refused because it ignores OAuth. The outer sandbox owns tool permissions,
264
+ including a read-only checkout for `--permission review`.
265
+
266
+ Without `--network-access`, the trusted setup resolves `api.anthropic.com` and allows
267
+ HTTPS to those addresses plus public DNS, with other external destinations dropped.
268
+ `--network-access` retains the existing public tool-network policy. Address resolution
269
+ is fixed for the attempt; restart a run if the inference service changes addresses.
270
+ Usage and observed models come from Claude's JSON result. Fresh runs are required;
271
+ remote `--continue-run` remains unsupported.
272
+
273
+ Remote Antigravity profiles use the worker's existing login, created once by the
274
+ operator in that worker user's normal HOME. Run
275
+ `cdx run agy-profile --remote worker --cwd /srv/project --prompt-file task.md --json`.
276
+ Matching `agy` versions are required on both hosts. Bubblewrap is mandatory;
277
+ each attempt has a memory-backed HOME and mounts only the worker's mode-600
278
+ `.gemini/antigravity-cli/antigravity-oauth-token` authentication file, read-only.
279
+ The installation is read-only. No token is copied into the volatile HOME or sent
280
+ to the controller. A worker token lock excludes concurrent cdx attempts;
281
+ independent native agy launches must be kept out of the same worker account slot.
282
+ `AGY_CLI_DISABLE_AUTO_UPDATE=true` is set. Runs use `-p --output-format json`,
283
+ with closed stdin, and require JSON status `SUCCESS` without refused actions.
284
+ Missing login and expired-session errors require operator login on the worker;
285
+ cdx does not launch an authentication command. Usage is read from the JSON result.
286
+
287
+ The conservative HTTPS candidate list is `oauth2.googleapis.com`,
288
+ `cloudcode-pa.googleapis.com` and `daily-cloudcode-pa.googleapis.com`, resolved
289
+ on the worker before isolation, plus DNS to `1.1.1.1`/`9.9.9.9`. Other public
290
+ traffic is dropped even with `--network-access`; private ranges remain denied.
291
+ This list has **not** been accepted against live inference in the mission sandbox.
292
+ The token remains read-only pending a measured receipt showing whether refresh
293
+ writes it in place. See
294
+ `logics/runbook/fragments/2026-11-03-antigravity-44674fc58c.md` for evidence and
295
+ the host measurement command. Local Antigravity headless runs retain their profile HOME
296
+ and credentials; the worker login, token lock and volatile HOME policy apply only
297
+ to remote runs.
298
+
299
+ Remote Ollama profiles use the worker's unauthenticated HTTP API at
300
+ `127.0.0.1:11434`, with `/api/show` followed by `/api/chat` (`stream: false`).
301
+ Use `cdx run local --remote worker --cwd /srv/project --model qwen3:8b --prompt-file question.txt --isolate-workspace --json`.
302
+ The prompt file is read on the launching host; the model must already exist on
303
+ the worker. This is text generation only, not an autonomous coding agent, and
304
+ it does not edit the repository. The result includes `execution_mode`, an
305
+ `execution_note`, generated text in `result`, and `prompt_eval_count` / `eval_count`
306
+ alongside normalized `usage` counters. Missing models and unreachable APIs fail
307
+ without pulling models or authenticating. Isolated clients use a private Unix
308
+ socket relay restricted to capability and non-streaming inference calls for the
309
+ selected model, with a read-only checkout and no network or model-file mounts.
310
+ `--network-access` does not enable networking for this text-only client.
311
+
243
312
  Known headless and completed interactive token usage is persisted in launch history and can be aggregated later. Interactive records are best-effort local data: Codex reads its latest cumulative rollout snapshot, Claude reads its assistant usage records, and Ollama — which writes no transcript of its own — is launched with `--verbose` so its per-response token counts land in the terminal capture cdx already takes. Antigravity reports no usage: its history is schema-less binary protobuf and its logs carry no usage line, so those sessions show no tokens by nature rather than by omission.
244
313
 
245
314
  `--continue-run RUN_ID` resumes the exact provider conversation from a finished Claude or Codex run, inferring its profile and pinning its recorded model and effort. Existing targets and settings may be supplied only if they match. The parent transcript must still exist. `parent_run_id` links the new run to its parent in run results, the registry, and history; `conversation_id` identifies the provider conversation. Detached continuations preserve these settings through the cdx supervisor. Codex continuation usage is measured between exact transcript snapshots before and after the run, since exec resume can report conversation totals. If either boundary is unavailable, per-run usage stays unknown with `usage_attribution: "continuation_usage_unavailable"` in history instead of counting earlier turns again. Continuation does not use automatic capacity recovery or model fallback, and cannot be combined with `--failover` or `--isolate-workspace`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.25.8",
3
+ "version": "0.25.9",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.25.8"
7
+ version = "0.25.9"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9.2"
@@ -0,0 +1,161 @@
1
+ """Local Antigravity statusLine capture; no credential or network access."""
2
+
3
+ import argparse
4
+ import base64
5
+ import json
6
+ import math
7
+ import os
8
+ import shlex
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ from datetime import datetime, timezone
13
+
14
+ STATE_NAME = "cdx-quota.json"
15
+ MARKER = "--cdx-antigravity-status"
16
+
17
+
18
+ def _directory(home):
19
+ return os.path.join(home, ".gemini", "antigravity-cli")
20
+
21
+
22
+ def _date(value):
23
+ try:
24
+ parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
25
+ return parsed if parsed.tzinfo is not None else None
26
+ except (ValueError, TypeError):
27
+ return None
28
+
29
+
30
+ def _quota(value):
31
+ result = {}
32
+ if not isinstance(value, dict):
33
+ return result
34
+ for name, bucket in value.items():
35
+ if not isinstance(bucket, dict):
36
+ continue
37
+ clean = {}
38
+ fraction = bucket.get("remaining_fraction")
39
+ if isinstance(fraction, (int, float)) and not isinstance(fraction, bool) and math.isfinite(fraction) and 0 <= fraction <= 1:
40
+ clean["remaining_fraction"] = fraction
41
+ reset = _date(bucket.get("reset_time"))
42
+ if reset:
43
+ clean["reset_time"] = reset.isoformat()
44
+ seconds = bucket.get("reset_in_seconds")
45
+ if isinstance(seconds, (int, float)) and not isinstance(seconds, bool) and math.isfinite(seconds) and seconds >= 0:
46
+ clean["reset_in_seconds"] = seconds
47
+ result[name] = clean
48
+ return result
49
+
50
+
51
+ def capture(payload, state_path, now=None):
52
+ """Persist only an allowlist, atomically with private permissions."""
53
+ if not isinstance(payload, dict):
54
+ return
55
+ now = now or datetime.now(timezone.utc)
56
+ state = {"time": now.isoformat(),
57
+ "plan_tier": payload.get("plan_tier") if isinstance(payload.get("plan_tier"), str) else None,
58
+ "quota": _quota(payload.get("quota"))}
59
+ directory = os.path.dirname(state_path)
60
+ os.makedirs(directory, exist_ok=True)
61
+ fd, temporary = tempfile.mkstemp(prefix=".cdx-quota-", dir=directory)
62
+ try:
63
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
64
+ json.dump(state, handle)
65
+ handle.write("\n")
66
+ os.replace(temporary, state_path)
67
+ finally:
68
+ if os.path.exists(temporary):
69
+ os.unlink(temporary)
70
+
71
+
72
+ def install(home):
73
+ """Merge the hook without replacing unrelated settings or user output."""
74
+ from .agent_notify import _read_json, _write_json
75
+
76
+ path = os.path.join(_directory(home), "settings.json")
77
+ settings = _read_json(path)
78
+ if settings is None:
79
+ return False
80
+ status = settings.get("statusLine") or {}
81
+ if not isinstance(status, dict) or (status and status.get("type") != "command"):
82
+ return False
83
+ previous = status.get("command") or ""
84
+ if not isinstance(previous, str):
85
+ return False
86
+ try:
87
+ parts = shlex.split(previous, posix=os.name != "nt")
88
+ except ValueError:
89
+ parts = []
90
+ own_command = (len(parts) >= 3 and os.path.basename(parts[1].strip('"')) == "antigravity_status.py"
91
+ and parts[2] == MARKER)
92
+ if own_command:
93
+ index = parts.index("--previous") + 1 if "--previous" in parts else len(parts)
94
+ encoded = parts[index] if index < len(parts) else ""
95
+ else:
96
+ encoded = base64.urlsafe_b64encode(previous.encode()).decode()
97
+ args = [sys.executable, os.path.abspath(__file__), MARKER,
98
+ "--state", os.path.join(_directory(home), STATE_NAME)]
99
+ if encoded:
100
+ args += ["--previous", encoded]
101
+ command = subprocess.list2cmdline(args) if os.name == "nt" else shlex.join(args)
102
+ merged = {**status, "type": "command", "command": command}
103
+ if settings.get("statusLine") == merged:
104
+ return True
105
+ return _write_json(path, {**settings, "statusLine": merged})
106
+
107
+
108
+ def read_status(home, now=None):
109
+ """Expose every bucket; use only fresh Gemini windows for availability."""
110
+ now = now or datetime.now(timezone.utc)
111
+ try:
112
+ with open(os.path.join(_directory(home), STATE_NAME), encoding="utf-8") as handle:
113
+ state = json.load(handle)
114
+ except (OSError, ValueError):
115
+ state = {}
116
+ if not isinstance(state, dict):
117
+ state = {}
118
+ at = _date(state.get("time"))
119
+ age = max(0, (now - at).total_seconds()) if at else None
120
+ stale = age is not None and age > 24 * 60 * 60
121
+ quota = _quota(state.get("quota"))
122
+ for bucket in quota.values():
123
+ reset = _date(bucket.get("reset_time"))
124
+ valid = at is not None and not stale and reset is not None and reset > now
125
+ bucket["status"] = "stale" if stale else "current" if valid else "unknown"
126
+ if not valid:
127
+ bucket["remaining_fraction"] = None
128
+ result = {"updated_at": at.isoformat() if at else None, "reading_age_seconds": age,
129
+ "quota_stale": stale, "quota_status": "stale" if stale else "current" if at else "unknown",
130
+ "plan_tier": state.get("plan_tier"), "quota": quota}
131
+ for window, name in (("5h", "gemini-5h"), ("week", "gemini-weekly")):
132
+ bucket = quota.get(name, {})
133
+ reset = _date(bucket.get("reset_time"))
134
+ valid = at is not None and not stale and reset is not None and reset > now
135
+ fraction = bucket.get("remaining_fraction") if valid else None
136
+ result[f"remaining_{window}_pct"] = fraction * 100 if fraction is not None else None
137
+ result[f"reset_{window}_at"] = reset.astimezone().strftime("%b %d %H:%M") if valid else None
138
+ result[f"reset_{window}_at_iso"] = reset.isoformat() if valid else None
139
+ return result
140
+
141
+
142
+ def main(stdin=None):
143
+ parser = argparse.ArgumentParser()
144
+ parser.add_argument(MARKER, action="store_true")
145
+ parser.add_argument("--state", required=True)
146
+ parser.add_argument("--previous", default="")
147
+ args = parser.parse_args()
148
+ raw = (stdin or sys.stdin).read()
149
+ try:
150
+ capture(json.loads(raw), args.state)
151
+ except (ValueError, OSError, TypeError):
152
+ # A failed capture must not suppress the user's existing status line.
153
+ pass
154
+ if args.previous:
155
+ command = base64.urlsafe_b64decode(args.previous).decode()
156
+ return subprocess.run(command, shell=True, input=raw, text=True, check=False).returncode
157
+ return 0
158
+
159
+
160
+ if __name__ == "__main__":
161
+ raise SystemExit(main())
@@ -0,0 +1,45 @@
1
+ """Dedicated controller-side Claude setup token, never a worker profile."""
2
+
3
+ import os
4
+ import re
5
+ import stat
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from .errors import CdxError
10
+
11
+ TOKEN_FILE = '.cdx-remote-claude-token'
12
+
13
+
14
+ def store_token(session, token):
15
+ token = token.strip()
16
+ if not re.fullmatch(r'[A-Za-z0-9._-]{1,16384}', token):
17
+ raise CdxError('Provide one Claude setup token on standard input.')
18
+ home = Path(session['authHome'])
19
+ home.mkdir(parents=True, exist_ok=True)
20
+ descriptor, temporary = tempfile.mkstemp(prefix='.claude-token-', dir=home)
21
+ try:
22
+ with os.fdopen(descriptor, 'w') as handle:
23
+ handle.write(token)
24
+ os.replace(temporary, home / TOKEN_FILE)
25
+ finally:
26
+ if os.path.exists(temporary):
27
+ os.unlink(temporary)
28
+
29
+
30
+ def read_token(session):
31
+ try:
32
+ path = Path(session['authHome']) / TOKEN_FILE
33
+ with open(path, opener=lambda name, flags: os.open(name, flags | getattr(os, "O_NOFOLLOW", 0))) as handle:
34
+ info = os.fstat(handle.fileno())
35
+ if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600 or (hasattr(os, "getuid") and info.st_uid != os.getuid()):
36
+ raise ValueError('Unsafe token file')
37
+ token = handle.read(16385).strip()
38
+ if not re.fullmatch(r'[A-Za-z0-9._-]{1,16384}', token):
39
+ raise ValueError('Invalid token')
40
+ return token
41
+ except (OSError, ValueError) as error:
42
+ raise CdxError(
43
+ f"Remote Claude requires a dedicated setup token; run cdx auth claude-token {session['name']} "
44
+ "with the token from claude setup-token on standard input.", code='remote_claude_token_missing',
45
+ ) from error
package/src/cli.py CHANGED
@@ -243,9 +243,11 @@ def _print_help(use_color=False):
243
243
  f" {_style('cdx next [--json] [--refresh]', '36', use_color)}",
244
244
  f" {_style('cdx agents [--max-age-hours N] [--json]', '36', use_color)}",
245
245
  f" {_style('cdx reset <name> [--yes] [--json]', '36', use_color)}",
246
+ f" {_style('cdx auth claude-token <profile> [--json]', '36', use_color)}",
246
247
  f" {_style('cdx auth refresh <name|all> [--json]', '36', use_color)}",
247
248
  f" {_style('cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json', '36', use_color)}",
248
249
  f" {_style('cdx run [session] [--remote SSH_TARGET] --cwd PATH [--project PATH] (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--continue-run RUN_ID] [--detach] [--failover] [--refresh] --json', '36', use_color)}",
250
+ " Remote Ollama: text generation only, not an autonomous coding agent; no repository edits. Requires an installed worker model; no authentication.",
249
251
  f" {_style('cdx runs [--limit N] [--since 7d|today|DATE] [--running] --json', '36', use_color)}",
250
252
  f" {_style('cdx run-status <run_id> --json', '36', use_color)}",
251
253
  f" {_style('cdx run-report <run_id> --json', '36', use_color)}",
package/src/cli_args.py CHANGED
@@ -49,6 +49,7 @@ SESSIONS_USAGE = "Usage: cdx sessions [--all] --json"
49
49
  SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json"
50
50
  NEXT_USAGE = "Usage: cdx next [--json] [--refresh]"
51
51
  RUN_USAGE = "Usage: cdx run [session] [--remote SSH_TARGET] [--remote-prefix COMMAND] (--cwd|--dir) PATH [--project PATH] (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--network-access] [--isolate-workspace] [--timeout-seconds N] [--budget USD] [--label TEXT] [--max-parallel N] [--continue-run RUN_ID] [--detach] [--failover] [--refresh] --json"
52
+ RUN_USAGE += "\nRemote Ollama generates text only; it is not an autonomous coding agent and does not edit the repository. Requires an installed worker model; no authentication."
52
53
  RUN_JSON_REQUIRED = "cdx run: --json is required."
53
54
  RUN_TARGET_REQUIRED = "cdx run: specify a session name or --provider PROVIDER."
54
55
  RUN_SESSION_PROVIDER_CONFLICT = "cdx run: cannot specify both a session name and --provider."
@@ -593,6 +594,7 @@ def cdx_schema():
593
594
  "logics_manager_update_available",
594
595
  "update_version_check_failed",
595
596
  "update_version_mismatch",
597
+ "update_unverified",
596
598
  "logics_manager_missing",
597
599
  "logics_viewer_failed",
598
600
  "logics_viewer_root_invalid",
@@ -3,6 +3,7 @@
3
3
  Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
4
4
  """
5
5
 
6
+ import sys
6
7
  import time
7
8
  import uuid
8
9
 
@@ -53,6 +54,30 @@ def _refresh_result(session, diagnostic):
53
54
 
54
55
 
55
56
  def handle_auth(rest, ctx):
57
+ if rest and rest[0] == "claude-token":
58
+ json_flag, args = _parse_json_flag(rest[1:])
59
+ if len(args) != 1:
60
+ raise CdxError("Usage: cdx auth claude-token <profile> [--json]")
61
+ session = ctx["service"]["get_session"](args[0])
62
+ if not session or session["provider"] != PROVIDER_CLAUDE:
63
+ raise CdxError("Select an existing Claude profile for cdx auth claude-token.")
64
+ if ctx["stdin_is_tty"]:
65
+ raise CdxError("Read the Claude setup token from standard input, without an interactive terminal.")
66
+ from ..claude_remote_token import store_token
67
+ stream = ctx.get("prompt_stdin") or sys.stdin
68
+ token = stream.read(16385)
69
+ if isinstance(token, bytes):
70
+ try:
71
+ token = token.decode("utf-8")
72
+ except UnicodeDecodeError as error:
73
+ raise CdxError("Provide one Claude setup token on standard input.") from error
74
+ store_token(session, token)
75
+ message = f"Stored remote Claude setup token for {session['name']}"
76
+ if json_flag:
77
+ _write_json(ctx, _json_success("auth.claude-token", message))
78
+ else:
79
+ ctx["out"](message + "\n")
80
+ return 0
56
81
  parsed = _parse_auth_args(rest)
57
82
  target = parsed["target"]
58
83
  if target == "all":
@@ -53,6 +53,8 @@ def _format_update_all_result(result, use_color=False):
53
53
  return _warn(f"skipped: {result['name']} (blocked by {result['blocked_by']})", use_color)
54
54
  if result.get("returncode") == 0:
55
55
  versions = f" ({result['version_before']} -> {result['version_after']})" if "version_after" in result else ""
56
+ if result.get("warning"):
57
+ return _warn(f"done: {result['name']}{versions}\n {result['warning']['message']}", use_color)
56
58
  return _success(f"done: {result['name']}{versions}", use_color)
57
59
  detail = str(result.get("stderr") or result.get("stdout") or "No error output.").strip().replace("\n", " ")
58
60
  detail = detail[-500:]
@@ -709,7 +711,8 @@ def handle_update(rest, ctx):
709
711
  failed = [row for row in results if row.get("returncode") != 0]
710
712
  message = "Applied workstation update plan" if not failed else "Workstation update plan completed with failures"
711
713
  if parsed["json"]:
712
- _write_json(ctx, _json_success("update.all", message, plan=plan, results=results, failed=len(failed)))
714
+ warnings = [dict(row["warning"], name=row["name"]) for row in results if row.get("warning")]
715
+ _write_json(ctx, _json_success("update.all", message, warnings=warnings, plan=plan, results=results, failed=len(failed)))
713
716
  else:
714
717
  ctx["out"](f"{_success(message, ctx['use_color']) if not failed else _warn(message, ctx['use_color'])}\n")
715
718
  return 1 if failed else 0
@@ -713,16 +713,26 @@ def handle_run(rest, ctx):
713
713
  session = selected["session"]
714
714
  selection_row = selected.get("row") or {}
715
715
 
716
+ if parsed.get("remote") and session["provider"] == "antigravity":
717
+ parsed["isolate_workspace"] = True
716
718
  remote_version = None
717
719
  if parsed.get("remote"):
718
- if session["provider"] != "codex":
719
- raise CdxError("--remote is only available for Codex profiles.", code="remote_provider_unsupported")
720
+ if session["provider"] not in ("codex", "claude", "ollama", "antigravity"):
721
+ raise CdxError("--remote is only available for Codex, Claude, Ollama and Antigravity profiles.", code="remote_provider_unsupported")
720
722
  if parsed.get("continue_run"):
721
723
  raise CdxError("--remote requires a fresh run without --continue-run.")
724
+ if session["provider"] == "ollama" and not (parsed.get("model") or (session.get("launch") or {}).get("model")):
725
+ raise CdxError("Remote Ollama text generation requires --model or a configured local model.")
726
+ if session["provider"] == "claude":
727
+ from ..claude_remote_token import read_token
728
+ read_token(session)
729
+ parsed["isolate_workspace"] = True
722
730
  remote_version = preflight(
723
731
  parsed["remote"], {**os.environ, **(ctx.get("env") or {})},
724
732
  remote_prefix=parsed.get("remote_prefix"), isolate_workspace=parsed.get("isolate_workspace", False),
725
- cwd=parsed["cwd"], network=parsed.get("network_access", False), writable=parsed.get("permission") != "review")
733
+ cwd=parsed["cwd"], network=parsed.get("network_access", False), writable=parsed.get("permission") != "review",
734
+ **({"provider": "ollama", "model": parsed.get("model") or (session.get("launch") or {}).get("model")}
735
+ if session["provider"] == "ollama" else {"provider": session["provider"]}))
726
736
  cwd = os.path.abspath(parsed["cwd"])
727
737
  if not parsed.get("remote") and not os.path.isdir(cwd):
728
738
  raise CdxError(f"Invalid cwd: {parsed['cwd']}", code="invalid_cwd")
@@ -1074,6 +1084,9 @@ def handle_run(rest, ctx):
1074
1084
  message = f"Every eligible session was rate limited: {', '.join(attempted)}."
1075
1085
  elif failover_error_code == "failover_limit_reached":
1076
1086
  message = f"Failover stopped after {MAX_FAILOVER_TRANSITIONS} transitions: {', '.join(attempted)}."
1087
+ elif run_info.get("provider_error"):
1088
+ failover_error_code = run_info["provider_error"]
1089
+ message = run_info["provider_message"]
1077
1090
  elif run_info.get("timed_out"):
1078
1091
  message = "Provider process timed out."
1079
1092
  else:
@@ -1,17 +1,11 @@
1
1
  """Best-effort usage readers for interactive provider sessions.
2
2
 
3
- Three providers report usage and one does not:
4
-
5
- - Claude and Codex write their own JSONL transcripts, resolved by the
6
- conversation id cdx already holds.
7
- - Ollama writes nothing. Its counts exist only in `--verbose` terminal output,
8
- so they are parsed out of the PTY capture cdx takes for every launch.
9
- - Antigravity has no retrievable source. Its conversation history is
10
- schema-less binary protobuf under `~/.gemini/antigravity-cli/conversations/`
11
- with no `.proto` available to decode field numbers into token counts, and its
12
- logs are plain glog with no usage-bearing line at any verbosity `agy --help`
13
- exposes. This was investigated and is a dead end, not an oversight -- do not
14
- spend the afternoon rediscovering it.
3
+ - Claude and Codex expose per-session token counts in JSONL transcripts.
4
+ - Ollama exposes counts in the verbose terminal output captured by cdx.
5
+ - Antigravity exposes quota through interactive statusLine stdin (collected by
6
+ antigravity_status), and per-run tokens through `agy -p --output-format json`
7
+ (read by run_usage). Interactive per-run token counts remain unavailable;
8
+ quota changes are not token measurements.
15
9
  """
16
10
 
17
11
  import json