{"id":"obol","repo":"broomva/obol","url":"https://github.com/broomva/obol","name":"obol","description":"A Paseo plugin for running more than one subscription behind the same provider, switching which one your agents use without restarting the daemon, and seeing what they are consuming.","categories":["provider","monitoring"],"platforms":[],"caveats":[],"images":[],"themes":[],"health":{"manifestValid":true,"hasReadme":true,"hasLicense":false,"hasTests":true,"hasTypecheckScript":true,"updatedRecently":true},"scannedAt":"2026-10-15T12:30:21.666Z","addedAt":"2026-09-10T18:09:18+03:00","version":"0.0.0","security":{"status":"failed","blockingFindings":1,"advisoryFindings":0,"scannedAt":"2026-10-15T12:14:24.405Z","commit":"86b6100478ba581ea5ce40fd4b8db1e60888b216"},"scanError":"package.json version \"0.0.0\" is a placeholder; publish a real release version","paseoVersionRequirement":">=0.8.0-beta.1","descriptionNodes":[{"type":"text","text":"A Paseo plugin for running more than one subscription behind the same provider, switching which one your agents use without restarting the daemon, and seeing what they are consuming."}],"caveatNodes":[],"manifest":{"id":"obol","requirements":{"paseo":">=0.8.0-beta.1"}},"repoMeta":{"stars":1,"defaultBranch":"main","pushedAt":"2026-10-05T20:37:59Z"},"owner":{"login":"broomva","avatarUrl":"https://avatars.githubusercontent.com/u/11726633?v=4"},"installNotesHtml":"<pre><code class=\"language-bash\">npm install\nnpm run typecheck\nnpm test\npaseo plugin install /absolute/path/to/obol\npaseo plugin ls          # expect: running\n</code></pre>\n<p>Plugins must be enabled on the target daemon (<code>pluginsEnabled: true</code> in\n<code>$PASEO_HOME/config.json</code>, then <code>paseo reload</code>). Plugin code is trusted and\nunsandboxed: the server half runs on the daemon machine with access to its\nfiles, processes and credentials.</p>","readmeText":"# Obol\n\nA Paseo plugin for running more than one subscription behind the same provider,\nswitching which one your agents use without restarting the daemon, and seeing\nwhat they are consuming.\n\nTwo halves:\n\n- **Subscriptions** — bind a Paseo provider to an account, and swap live agents\n  onto it.\n- **Usage** — live subscription windows plus per-agent token counts.\n\n## How the swap works\n\nAn account is a credential store, and which store a provider process reads is\ndecided by one environment variable: `CLAUDE_CONFIG_DIR` for Claude, `CODEX_HOME`\nfor Codex. Paseo already honours both.\n\nObol registers `server.before(\"agent.session_open\")`, which Paseo calls on\n**create, resume, refresh and import**. Paseo does not persist environment\noverrides with the agent, so the hook is consulted fresh every time a provider\nsession opens — the currently bound account is always the one that takes effect.\n\nThat makes the swap two steps:\n\n1. Bind the provider to another account (`obol.accounts.select`).\n2. Reload the affected agents, which closes and reopens their provider sessions.\n\nReload is the part the plugin SDK does not expose: `agent.refresh()` in the SDK\nis a data refetch, not a session reopen. The reopen lives behind the daemon's\nagent-reload RPC, so the handler shells out to `paseo agent reload <id>` — the\nsupported route for daemon-local work from a server handler. A reload that fails\nis reported per agent; the binding itself has already been saved, so the agent\npicks it up the next time its session opens either way.\n\n### Scope: fleet, workspace, or one agent\n\nA binding is resolved most-specific-first — the agent's own, then its\nworkspace's, then the fleet-wide provider default. Because Obol never rewrites a\nshared credential store, nothing forces the fleet to move together: three agents\ncan sit on the work subscription while three others sit on personal, at the same\ntime.\n\nThat also decides what a swap reloads. The plan compares each agent's\n**effective** account before and after the change and reloads only the ones that\nactually moved, so changing the fleet default leaves a pinned agent alone —\nits subscription did not change, and reopening its session would cost a turn and\nbuy nothing. Measured on a live daemon:\n\n```\ncreate              [obol] bound claude -> claude-alpha via provider (create)\npin agent -> beta   reloaded 1\n                    [obol] bound claude -> claude-beta via agent (refresh)\nfleet default -> alpha   reloaded 0      # the pinned agent stayed put\n```\n\nPin one agent from its composer:\n\n```\n/obol claude-personal\n```\n\nA binding that names an account which no longer exists falls through to the next\nscope rather than stranding the session unbound.\n\n### A swap never interrupts a turn in flight\n\nReloading closes and reopens the provider session, and Paseo interrupts a\nrunning turn to do it (`interruptAgentIfRunning` in `session.ts`). On a daemon\nwith live work that would destroy a turn — including, on your own machine, the\nagent that may be driving the swap. So a swap reloads only agents that are not\nmid-turn and reports the rest as **deferred**. Deferring costs nothing: the\nrouter is re-consulted on every session open, so a busy agent picks the new\nbinding up as soon as its session next opens.\n\n### The conversation has to travel with the agent\n\n`CLAUDE_CONFIG_DIR` scopes the credential store **and** the conversation store.\nSwapping a live agent therefore moves its credentials and orphans its history:\nthe reopened session resumes by session id, and that id is a `.jsonl` file under\nthe account it came from. Measured, before this was handled:\n\n```\n[obol] bound claude -> claude-beta (refresh, keys: CLAUDE_CONFIG_DIR)\nClaude Code returned an error result:\n  No conversation found with session ID: a882dcb9-0767-41f4-965c-a12d678b7224\n\n$ grep -rl a882dcb9 .claude-alpha .claude-beta\n.claude-alpha/projects/-private-tmp-obol-demo/a882dcb9-....jsonl\n```\n\nSo before reloading an agent, Obol mirrors that agent's conversation into the\ntarget account (`server/history.ts`). The copy only ever adds files: the source\naccount is never modified, and an existing target file is replaced only by a\nstrictly newer source. The `projects/<dir>` name is computed with the same\nencoding Paseo uses (`packages/server/src/server/agent/providers/claude/\nproject-dir.ts`), so both land on the same directory.\n\n**The order matters.** Copying the conversation into a session that is already\nopen does nothing — the provider resolves its history when the session opens.\nMirroring after a reload leaves the same error in place; mirroring before it\nlets the same agent resume:\n\n```\nmirror → reload → send   {\"status\":\"completed\"}\nreload → mirror → send   {\"status\":\"error\",\"message\":\"No conversation found...\"}\n```\n\n### Switching clears the other account's variables\n\nIf one account sets two variables and the account replacing it sets one, the\nleftover would survive and the session would open against a mix of two\ncredential stores. Obol computes every key any account for that provider could\nset and clears all of them before applying the selected account's. See\n`server/routing.test.ts`.\n\n## Accounts\n\nObol proposes one account per `~/.claude*` and `~/.codex*` directory it finds:\n`~/.claude` becomes `claude-default`, `~/.claude-work` becomes `claude-work`.\nDiscovery only proposes rows. Nothing is bound until you select it.\n\nState lives at `$PASEO_HOME/obol/state.json` and is written atomically:\n\n```json\n{\n  \"accounts\": [\n    {\n      \"id\": \"claude-work\",\n      \"provider\": \"claude\",\n      \"label\": \"claude (work)\",\n      \"env\": { \"CLAUDE_CONFIG_DIR\": \"/Users/you/.claude-work\" }\n    }\n  ],\n  \"active\": { \"claude\": \"claude-work\" },\n  \"bindings\": [\n    { \"scope\": \"agent\", \"key\": \"<agent-id>\", \"provider\": \"claude\", \"accountId\": \"claude-personal\" }\n  ]\n}\n```\n\n`active` is the fleet default; `bindings` are the narrower overrides. A file\nwritten before scoped bindings existed still loads — `bindings` defaults to `[]`.\n\n`provider` is a Paseo provider id, so it also works with a provider profile\nalias from `docs/custom-providers.md`.\n\n## What the numbers mean\n\nThree different quantities get called \"usage\" and this plugin does not collapse\nthem.\n\n| Panel section          | What it is                                                              | What it is not                     |\n| ---------------------- | ----------------------------------------------------------------------- | ---------------------------------- |\n| Subscription windows   | Paseo's own quota fetcher, via `providers.listUsage()`                  | Not a per-account probe            |\n| Last-turn consumption  | The token counts each agent reported for its **most recent turn**       | Not a cumulative total, not a bill |\n\nTwo limits worth knowing before you read a number off the screen:\n\n- **Windows are not per-account, and for Claude they are not even per-directory.**\n  Paseo's fetchers read the daemon's own environment, not the per-agent override\n  (`quota-fetcher/providers/codex.ts:87`). For Claude it is worse: the fetcher\n  reads `CLAUDE_HOME` — a variable Claude Code does not have — and never\n  `CLAUDE_CONFIG_DIR`, then falls back to an *unscoped* Keychain item. Pointed at\n  an empty directory it still returns the default account's live usage, measured.\n  Claude Code scopes credentials as `Claude Code-credentials-<sha256(dir)[:8]>`;\n  the fetcher never reads the scoped item. Reported upstream on\n  [getpaseo/paseo#3589](https://github.com/getpaseo/paseo/issues/3589). Until it\n  lands, treat the Claude windows as \"some account on this machine\", not the\n  bound one.\n- **`lastUsage` is one turn.** Paseo exposes the last turn's tokens per agent,\n  not a running total, so the totals row is \"the sum of everyone's last turn\",\n  which is what the heading says.\n\nA cost that no agent reported reads `not reported`, never `$0.00`. An absent\nnumber is unknown, and unknown is not zero.\n\n## Requires Paseo 0.8\n\nObol is a 0.8 plugin and there is no 0.7 version of it to write. The mechanism it\nis built on — `server.before(\"agent.session_open\")`, which rewrites a session's\nlaunch environment — was **added in 0.8.0-beta.1** (#4435), along with slash\ncommands and the split `index.client` / `index.server` entry format. None of\nthose exist in 0.7.x.\n\nOn a 0.7 daemon the install fails while parsing the manifest, because\n`requirements` is itself a 0.8 key:\n\n```\nRequest failed: [{ \"code\": \"unrecognized_keys\", \"keys\": [\"requirements\"],\n  \"message\": \"Unrecognized key: \\\"requirements\\\"\" }]\n  requestType=plugin.directory.install.request\n```\n\nRemoving that key does not help — it only moves the failure to the entry format.\n\nAt the time of writing 0.8.0 has not reached stable; `v0.8.0-beta.1` is a\npre-release and `v0.7.2` is `Latest`. To run Obol today, join the beta channel:\n**Settings → About → Release channel → Beta**. It is reversible — switching back\nto stable installs the next stable update normally.\n\n## Install\n\n```bash\nnpm install\nnpm run typecheck\nnpm test\npaseo plugin install /absolute/path/to/obol\npaseo plugin ls          # expect: running\n```\n\nPlugins must be enabled on the target daemon (`pluginsEnabled: true` in\n`$PASEO_HOME/config.json`, then `paseo reload`). Plugin code is trusted and\nunsandboxed: the server half runs on the daemon machine with access to its\nfiles, processes and credentials.\n\n## Development\n\n```bash\nnpm run typecheck\nnpm test                 # pure logic: routing, ledger aggregation, formatting\npaseo plugin reload obol\npaseo plugin logs obol\n```\n\nThe client half must stay mobile-safe — React Native primitives only, colors\nfrom `theme.colors`. This audit must come back empty:\n\n```bash\ngrep -rnE \"document\\.|window\\.|localStorage|navigator\\.|<[a-z]+[ >]|className=|onClick=\" client/\n```\n"}