Skip to content

feat(usage): Claude Code's plan limits, signed-out logins, team seats, and boxes with no projects - #115

Open
tinhthanh wants to merge 3 commits into
cosscom:mainfrom
tinhthanh:feat/usage-claude-limits-upstream
Open

tinhthanh wants to merge 3 commits into
cosscom:mainfrom
tinhthanh:feat/usage-claude-limits-upstream

Conversation

@tinhthanh

@tinhthanh tinhthanh commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

The Usage plugin, brought up to date with what we've been running since this PR opened, and reviewed again before asking for a review.

What it does

  • Plan limits from Claude Code's own /usage. The Usage screen and its home widget show each Claude login's current session, its week and any per-model week, with their resets.
    • usage.py asks /usage at most every ten minutes per login, four logins at a time, each for at most 12 seconds.
    • Flags: --print --no-session-persistence --strict-mcp-config --settings '{"disableAllHooks":true}', plus BERTH_DRIVER=structured in its environment. So it leaves no transcript, starts no MCP server and runs no hooks. Without that, Shipyard's own Claude hooks reported an agent starting and stopping at every read: on one box we found about 2,000 such agent.ready/agent.exited events in the journal.
    • If a read fails, the token counts still show.
  • The widget shows each plan window once, and whose it is. A login used on several boxes is merged, even when the boxes print its reset a minute apart. With more than one login, each row names the login.
  • Signed-out logins. Claude Code keeps a profile after it loses the login, so each login is checked once per report with claude auth status (about 0.15s). One that Claude Code no longer holds shows Signed out with Sign in…, and its old limits are dropped.
  • Team seats say Claude Team (or Enterprise).
  • Two plans on one email (a team seat and a personal plan) both show, in the widget and on the Usage screen, told apart by the organisation's id.
  • A login's old limits never show as someone else's. A read that finishes with no limits clears them. So does a folder that is now signed in to another account. Only a read that doesn't finish (a timeout or an error exit) keeps the last good figures.
  • Boxes with no projects are read too (see below).

An API change to look at: POST /v1/exec without a location

  • On a box with no projects yet, it runs in the box user's home folder (internal/box/orchestrate.go, execDir). That's how the plugin reads such a box.
  • Once the box has a project, it's a 400 ("name the location to run in"). So no command can start outside a project to skip that project's before: gates. Thanks to the review for catching that.
  • An unknown location is still a 404. Tests cover all three cases.

Tests

  • plugins/usage/box/usage_test.py, run by app/src/lib/usage-py.test.ts in pnpm test and skipped without python3. It covers:
    • no hooks, and BERTH_DRIVER;
    • a reset read in the box's own zone across a daylight saving change;
    • a no-limits read clearing the old figures;
    • another account in the same folder;
    • Claude and Codex default logins kept apart, with real Codex limits;
    • signed-out logins;
    • hangs that cost one timeout and leave no process behind.
  • usage-plan.test.ts, usage-summary.test.ts and usage-limits.test.ts: the widget's and the Usage screen's merging and naming, including two plans of one email on two boxes and a box that listed no accounts.
  • Two reports at once ask each login's /usage once (an flock on the cache), and a report too big for exec drops the oldest limits after its sessions.
  • An orchestrate test for exec without a location.
  • Local checks:
    • typecheck:plugins, check:titles/type/grid/csp/themes, pnpm test (653 pass), build, tsc -p e2e, docs-site build.
    • The Python tests pass with TZ set to the machine's zone, Europe/Berlin and America/New_York.
    • go test ./internal/box passes except TestAttachPassesShiftEnter, which fails the same way on main on macOS.
  • All fixtures are synthetic (acme / example.com).

🤖 Generated with Claude Code

@vercel

vercel Bot commented Oct 15, 2026

Copy link
Copy Markdown

@tinhthanh is attempting to deploy a commit to the seanbrydonio's projects Team on Vercel.

A member of the Team first needs to authorize it.

@tinhthanh
tinhthanh marked this pull request as draft October 15, 2026 03:50
@sean-brydon

Copy link
Copy Markdown
Collaborator

Hey this looks good! Let me know when its not draft!!

@tinhthanh
tinhthanh force-pushed the feat/usage-claude-limits-upstream branch from 3912f53 to 5b51f3f Compare October 30, 2026 12:43
@tinhthanh tinhthanh changed the title feat(usage): show Claude Code's plan limits, read from its own /usage feat(usage): Claude Code's plan limits, signed-out logins, team seats, and boxes with no projects Oct 30, 2026
@tinhthanh
tinhthanh force-pushed the feat/usage-claude-limits-upstream branch 2 times, most recently from 6b01bbb to cc62cb5 Compare October 30, 2026 14:18
…gins, team seats, and boxes with no projects

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tinhthanh
tinhthanh force-pushed the feat/usage-claude-limits-upstream branch from cc62cb5 to 70c2515 Compare October 30, 2026 14:24
@tinhthanh
tinhthanh marked this pull request as ready for review October 30, 2026 14:25
@tinhthanh

Copy link
Copy Markdown
Contributor Author

Ready for review. It's brought up to date with what we've been running, and reviewed twice before asking. The description lists what changed. One thing to look at on your side is POST /v1/exec without a location (it runs in the box user's home folder now).

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 18 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="plugins/usage/box/usage_test.py">

<violation number="1" location="plugins/usage/box/usage_test.py:319">
P3: When no stand-in writes its `child` marker, this test skips instead of failing, so the "nothing left running" guarantee is silently unchecked on slow machines. Make the stand-ins start before the timeout, for example by waiting for the marker file with a bounded poll before calling `claude_limits()`, so the check always runs.</violation>
</file>

<file name="plugins/usage/src/summary.ts">

<violation number="1" location="plugins/usage/src/summary.ts:25">
P2: Signed-out warnings discard organization and box identity, so distinct team/personal profiles can collapse into one label. Include the organization or box fallback when deduplicating signed-out logins.</violation>

<violation number="2" location="plugins/usage/src/summary.ts:114">
P2: The home widget distinguishes same-email organizations internally but names them only by account. Same-email team and personal plans can therefore render identical labels and React keys; include the organization in the display identity.</violation>
</file>

<file name="plugins/usage/src/data.ts">

<violation number="1" location="plugins/usage/src/data.ts:194">
P2: The limits filter compares the UTC date of `at` with the box-local day. On boxes east of UTC, limits read in the early local morning fall on the previous UTC date and drop out of the Today view. Compare against the box's local day, e.g. convert `at` with the box's offset as `local_day` does in usage.py.</violation>
</file>

Reply to a comment to ask cubic a question or push back. It learns from your replies.

View guided diff | Re-trigger cubic

Comment thread internal/box/orchestrate.go Resolved
export function signedOutLogins(reports: { report: Report }[]): string[] {
const out = new Set<string>();
for (const { report } of reports) {
for (const s of report.signed_out ?? []) out.add(s.email ? `${s.account} (${s.email})` : s.account);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Signed-out warnings discard organization and box identity, so distinct team/personal profiles can collapse into one label. Include the organization or box fallback when deduplicating signed-out logins.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At plugins/usage/src/summary.ts, line 25:

<comment>Signed-out warnings discard organization and box identity, so distinct team/personal profiles can collapse into one label. Include the organization or box fallback when deduplicating signed-out logins.</comment>

<file context>
@@ -0,0 +1,156 @@
+export function signedOutLogins(reports: { report: Report }[]): string[] {
+  const out = new Set<string>();
+  for (const { report } of reports) {
+    for (const s of report.signed_out ?? []) out.add(s.email ? `${s.account} (${s.email})` : s.account);
+  }
+  return [...out].sort();
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is. Both entries would still read the same, because a signed-out login carries no box in the report, so a different key would only print the same text twice. The two-plans case is about logins that are signed in. A signed-out profile is told by its folder and email, which is what the person signs in again with.

const names = emails.map((e) => e.split("@")[0]);
logins.forEach((l, i) => {
let name = names.filter((n) => n === names[i]).length > 1 ? emails[i] : names[i];
if (emails.filter((e) => e === emails[i]).length > 1) name = `${names[i]} · ${of(l).account}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The home widget distinguishes same-email organizations internally but names them only by account. Same-email team and personal plans can therefore render identical labels and React keys; include the organization in the display identity.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At plugins/usage/src/summary.ts, line 114:

<comment>The home widget distinguishes same-email organizations internally but names them only by account. Same-email team and personal plans can therefore render identical labels and React keys; include the organization in the display identity.</comment>

<file context>
@@ -0,0 +1,156 @@
+    const names = emails.map((e) => e.split("@")[0]);
+    logins.forEach((l, i) => {
+      let name = names.filter((n) => n === names[i]).length > 1 ? emails[i] : names[i];
+      if (emails.filter((e) => e === emails[i]).length > 1) name = `${names[i]} · ${of(l).account}`;
+      who.set(`${agent}:${l}`, name);
+    });
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this for the widget. It's a small card with no box to name, and the case needs one email signed in to two plans, both as the default login of different boxes. The Usage screen tells them apart by box (4900d8b). The widget's React key is label@resets, and the two windows' resets differ, so the keys don't collide.

Comment thread app/src/lib/usage-py.test.ts Resolved
Comment thread plugins/usage/src/usage-view.tsx Resolved
Comment thread plugins/usage/README.md Resolved
except ProcessLookupError:
alive = False
self.assertFalse(alive, f"{d.name}'s child {pid} is still running")
if not started:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When no stand-in writes its child marker, this test skips instead of failing, so the "nothing left running" guarantee is silently unchecked on slow machines. Make the stand-ins start before the timeout, for example by waiting for the marker file with a bounded poll before calling claude_limits(), so the check always runs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At plugins/usage/box/usage_test.py, line 319:

<comment>When no stand-in writes its `child` marker, this test skips instead of failing, so the "nothing left running" guarantee is silently unchecked on slow machines. Make the stand-ins start before the timeout, for example by waiting for the marker file with a bounded poll before calling `claude_limits()`, so the check always runs.</comment>

<file context>
@@ -0,0 +1,431 @@
+            except ProcessLookupError:
+                alive = False
+            self.assertFalse(alive, f"{d.name}'s child {pid} is still running")
+        if not started:
+            self.skipTest("neither stand-in started its child in time on this machine")
+
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the skip. It only applies when the machine couldn't start the stand-in's child before the timeout; the one-timeout bound is asserted either way. Waiting for the marker before calling would change what's tested: the point is a read cut off while its child runs.

Comment thread docs/changelog.mdx Resolved
Comment thread app/src/lib/mock-usage.ts Resolved
Comment thread docs/guides/orchestration.mdx Resolved
…h no projects, logins told apart by organisation everywhere, one /usage read at a time

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 12 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread plugins/usage/box/usage.py Resolved
Comment thread app/src/lib/mock-usage.ts Resolved
Comment thread internal/box/orchestrate.go Resolved
…ecks for projects without asking git

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants