Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3c4f59e
feat(mobile): allow overriding iOS team and bundle ID via env
jetblk Jul 12, 2026
f4a295c
Provider Usage in the T3 Code mobile app (#1)
jetblk Jul 12, 2026
6a18d70
fix(mobile): stop Usage & Limits pull-to-refresh spinner from hanging
jetblk Jul 12, 2026
3d53c89
Merge remote-tracking branch 'upstream/main'
jetblk Jul 17, 2026
0558103
ci: fork nightly release + JetBlk app name
jetblk Jul 17, 2026
ca7e884
ci(nightly): install @t3tools/scripts deps for the version stamper
jetblk Jul 17, 2026
58dcede
ci(nightly): ship a self-contained bundle via pnpm deploy
jetblk Jul 17, 2026
4e10b2c
fix(nightly): ship the web client; add node runbook
jetblk Jul 17, 2026
e0d9651
docs: add the node systemd unit referenced by nodes.md
jetblk Jul 17, 2026
5f1104d
docs(nodes): correct asset size; document web client + channel switcher
jetblk Jul 17, 2026
8d15ed1
ci(nightly): skip docs-only builds; publish an immutable pin target
jetblk Jul 17, 2026
db3a315
docs(nodes): show both releases in the pipeline diagram
jetblk Jul 17, 2026
53d27a1
feat(web): show provider usage (#2)
jetblk Jul 17, 2026
d106a28
fix(web): move provider usage into model picker (#3)
jetblk Jul 17, 2026
94a3380
ci(nightly): run on a cron, not on every push to main (#4)
jetblk Jul 17, 2026
ebe84c9
Merge remote-tracking branch 'upstream/main'
jetblk Jul 17, 2026
cc9b037
fix(provider-usage): follow upstream CLAUDE_CONFIG_DIR semantics for …
jetblk Jul 17, 2026
9197369
fix(provider-usage): headline session → weekly → credits in strict pr…
jetblk Jul 17, 2026
c45742b
Merge remote-tracking branch 'upstream/main'
jetblk Jul 17, 2026
27e6a1f
Merge remote-tracking branch 'upstream/main'
jetblk Jul 18, 2026
60d1917
ci(nightly): check for changes every 3 hours, matching upstream
jetblk Jul 18, 2026
5af3fa8
Improve provider usage tracking
jetblk Jul 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 262 additions & 0 deletions .github/workflows/nightly-fork.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
# Fork-local nightly for jetblk/t3code.
#
# Upstream's release.yml is unusable here: it needs pingdotgg's self-hosted
# `blacksmith-*` runners, their Clerk/Cloudflare/Apple/Azure secrets, and it
# publishes the `t3` npm package we don't own. This builds the one artifact our
# nodes actually consume — the self-contained `vp pack` server bundle — and
# publishes it as a rolling `nightly` GitHub Release.
#
# Nodes pull that asset on service restart (restart == update), mirroring the
# `npx t3@nightly` behaviour we replaced. The repo is public, so nodes need no
# auth to download it.
#
# Checks every 3 hours (mirroring upstream's release.yml cadence), and only
# builds when main actually moved (see check_changes) — a quiet window costs a
# ~10s gate check, not a build. Need a build sooner? Dispatch it manually; that
# path always builds.
name: Fork nightly

on:
# Every 3 hours — same cadence upstream's release.yml uses for its nightly
# channel. The offset minute is deliberate: crons on the hour queue behind
# GitHub's peak load and get delayed or dropped.
schedule:
- cron: "37 */3 * * *"
# Manual runs skip the change gate below and always build — this is the
# "I want my merge on a node now" path.
workflow_dispatch:

permissions:
contents: read

# Never cancel in flight: the publish step deletes the `nightly` release before
# recreating it, and a cancel inside that window would leave nodes with no
# release to pull at all (their ExecStartPre is best-effort, so they would
# silently keep running a stale build). Upstream's release.yml likewise has no
# concurrency group.
concurrency:
group: fork-nightly
cancel-in-progress: false

jobs:
check_changes:
name: Check for changes since last nightly
# Only the cron needs a gate; a manual dispatch is an explicit "build now".
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
has_changes: ${{ steps.check.outputs.has_changes }}
env:
GH_TOKEN: ${{ github.token }}
steps:
# No checkout on purpose — the compare API answers this in seconds, so a
# quiet night costs ~10s instead of a full install. Every failure path
# here builds rather than skips: a missed build is worse than a spare one.
- id: check
name: Compare HEAD to the last published nightly
shell: bash
run: |
set -euo pipefail

build() { echo "has_changes=true" >> "$GITHUB_OUTPUT"; exit 0; }

# The rolling release records the exact commit it was built from.
last="$(gh release view nightly --repo "$GITHUB_REPOSITORY" \
--json targetCommitish -q .targetCommitish 2>/dev/null || true)"
if [ -z "$last" ]; then
echo "No nightly release to compare against — building."
build
fi

if ! json="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${last}...${GITHUB_SHA}" 2>/dev/null)"; then
echo "Compare against ${last} failed (force-push? deleted commit?) — building."
build
fi

# The compare API caps .files at 300. A diff that large is an upstream
# sync, which has code in it by definition — don't trust the truncated
# list, just build.
if [ "$(jq '.files | length' <<<"$json")" -ge 300 ]; then
echo "Compare truncated at 300 files — building."
build
fi

# Same intent as the old `paths-ignore: docs/**, **/*.md`, but applied
# to the whole day's diff rather than one push: a day of nothing but
# docs commits now skips, where paths-ignore judged each push alone.
# (Path filters only apply to push/pull_request, so they would be dead
# config under `schedule`.)
code="$(jq -r '.files[].filename' <<<"$json" | grep -vE '^docs/|\.md$' || true)"
if [ -z "$code" ]; then
echo "No code changes since ${last} — skipping this build."
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "Code changed since ${last}:"
echo "$code"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi

build:
name: Build and publish server bundle
needs: [check_changes]
# `!failure() && !cancelled()` is what lets a *skipped* gate through (a
# skipped `needs` job otherwise skips its dependents) while still halting on
# a *broken* one — `always()` would build even when the gate errored.
if: |
!failure() && !cancelled() &&
(github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true')
# Only this job publishes; the gate above needs read-only.
permissions:
contents: write
# GitHub-hosted: the fork has no access to upstream's blacksmith runners.
runs-on: ubuntu-latest
timeout-minutes: 20
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
with:
# package.json pins Node ^24.13.1 — required by the TS build scripts.
node-version-file: package.json
cache: true
run-install: |
args:
- --filter=t3...
# scripts/update-release-package-versions.ts imports
# @effect/platform-node, which only @t3tools/scripts pulls in.
- --filter=@t3tools/scripts...
# The server serves the web client from dist/client.
- --filter=@t3tools/web...

- id: version
name: Resolve fork version
shell: bash
env:
RUN_STARTED_AT: ${{ github.run_started_at }}
run: |
set -euo pipefail
# Track upstream's stable base from package.json (nightly versions are
# never committed upstream, so main always carries the last stable),
# then mark it as ours so it is obvious which build a node is running.
base="$(node -p "require('./apps/server/package.json').version")"
# Date the run, not the clock: bare `date -u` reads whenever this step
# happens to run, so a build starting at 23:59 UTC would stamp
# tomorrow. Mirrors upstream's NIGHTLY_DATE: github.run_started_at.
day="$(date -u -d "$RUN_STARTED_AT" +%Y%m%d)"
Comment on lines +139 to +150

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Provide a fallback for RUN_STARTED_AT to prevent script failure.

The static analysis tool actionlint reports that run_started_at is not a defined property on the github context object. If this property evaluates to an empty string, the date -u -d "" command will fail with an invalid date error, breaking the workflow due to set -euo pipefail.

Add a fallback to "now" (${RUN_STARTED_AT:-now}) to ensure the script always succeeds, even if the context property is missing.

🛡️ Proposed fix
       env:
         RUN_STARTED_AT: ${{ github.run_started_at }}
       run: |
         set -euo pipefail
         # Track upstream's stable base from package.json (nightly versions are
         # never committed upstream, so main always carries the last stable),
         # then mark it as ours so it is obvious which build a node is running.
         base="$(node -p "require('./apps/server/package.json').version")"
         # Date the run, not the clock: bare `date -u` reads whenever this step
         # happens to run, so a build starting at 23:59 UTC would stamp
         # tomorrow. Mirrors upstream's NIGHTLY_DATE: github.run_started_at.
-        day="$(date -u -d "$RUN_STARTED_AT" +%Y%m%d)"
+        day="$(date -u -d "${RUN_STARTED_AT:-now}" +%Y%m%d)"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
env:
RUN_STARTED_AT: ${{ github.run_started_at }}
run: |
set -euo pipefail
# Track upstream's stable base from package.json (nightly versions are
# never committed upstream, so main always carries the last stable),
# then mark it as ours so it is obvious which build a node is running.
base="$(node -p "require('./apps/server/package.json').version")"
# Date the run, not the clock: bare `date -u` reads whenever this step
# happens to run, so a build starting at 23:59 UTC would stamp
# tomorrow. Mirrors upstream's NIGHTLY_DATE: github.run_started_at.
day="$(date -u -d "$RUN_STARTED_AT" +%Y%m%d)"
env:
RUN_STARTED_AT: ${{ github.run_started_at }}
run: |
set -euo pipefail
# Track upstream's stable base from package.json (nightly versions are
# never committed upstream, so main always carries the last stable),
# then mark it as ours so it is obvious which build a node is running.
base="$(node -p "require('./apps/server/package.json').version")"
# Date the run, not the clock: bare `date -u` reads whenever this step
# happens to run, so a build starting at 23:59 UTC would stamp
# tomorrow. Mirrors upstream's NIGHTLY_DATE: github.run_started_at.
day="$(date -u -d "${RUN_STARTED_AT:-now}" +%Y%m%d)"
🧰 Tools
🪛 actionlint (1.7.12)

[error] 140-140: property "run_started_at" is not defined in object type {action: string; action_path: string; action_ref: string; action_repository: string; action_status: string; actor: string; actor_id: string; api_url: string; artifact_cache_size_limit: number; base_ref: string; env: string; event: object; event_name: string; event_path: string; graphql_url: string; head_ref: string; job: string; output: string; path: string; ref: string; ref_name: string; ref_protected: bool; ref_type: string; repository: string; repository_id: string; repository_owner: string; repository_owner_id: string; repository_visibility: string; repositoryurl: string; retention_days: number; run_attempt: string; run_id: string; run_number: string; secret_source: string; server_url: string; sha: string; state: string; step_summary: string; token: string; triggering_actor: string; workflow: string; workflow_ref: string; workflow_sha: string; workspace: string}

(expression)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/nightly-fork.yml around lines 139 - 150, Update the date
calculation in the workflow step using RUN_STARTED_AT so it passes a fallback of
“now” when the variable is empty or unset, while preserving the existing
run-start timestamp behavior when available. Keep the change scoped to the day
assignment near the base-version logic.

Source: Linters/SAST tools

version="${base}-jetblk.${day}.${GITHUB_RUN_NUMBER}"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Building $version"

- name: Stamp version into releasable packages
run: node scripts/update-release-package-versions.ts "${{ steps.version.outputs.version }}"

# Gate on typecheck + the provider-usage tests only. The full server suite
# has pre-existing ACP-transport failures unrelated to this fork; gating on
# it would block every release.
- name: Typecheck
run: vp run --filter t3 --filter @t3tools/contracts typecheck

# `vp test` is vitest; run it from each package so its own config applies.
- name: Test contracts (provider usage)
working-directory: packages/contracts
run: vp test run src/providerUsage.test.ts

- name: Test server (provider usage)
working-directory: apps/server
run: |
vp test run \
src/provider/Layers/ClaudeUsage.test.ts \
src/provider/Layers/CodexUsage.test.ts \
src/provider/Layers/ProviderUsageService.test.ts

- name: Build web client
run: vp run --filter @t3tools/web build

- name: Build server bundle
run: vp run --filter t3 build:bundle

# `vp pack` only produces dist/bin.mjs. The server serves the browser UI
# from dist/client, so without this it answers "No static directory
# configured and no dev URL set." This mirrors what apps/server/scripts/
# cli.ts `build` does (copy apps/web/dist -> dist/client); we do it inline
# rather than calling that script so we skip its *development* icon
# overrides, which would ship dev icons in a release build.
- name: Bundle web client into dist/client
run: |
set -euo pipefail
test -f apps/web/dist/index.html
rm -rf apps/server/dist/client
cp -r apps/web/dist apps/server/dist/client
test -f apps/server/dist/client/index.html

- name: Package self-contained bundle
shell: bash
run: |
set -euo pipefail
echo "${{ steps.version.outputs.version }}" > apps/server/dist/VERSION
# `vp pack` externalises dependencies (and 5 of them use pnpm's
# `catalog:` protocol), so dist/ alone cannot run. `pnpm deploy` copies
# the package plus its *production* deps — including the native ones
# (node-pty, sqlite) — into one portable tree. Safe because every node
# is linux-x64, matching this runner; a non-linux node would need its
# own build.
corepack enable # packageManager pins pnpm@11.10.0
pnpm deploy --filter t3 --prod --legacy deploy-out
tar -czf t3-server.tgz -C deploy-out .
du -h t3-server.tgz

# Two releases per build, on purpose:
# v<version> immutable, never deleted — the pin/rollback target.
# nightly rolling pointer, recreated each run — the stable URL nodes
# curl, so they never need to know the current version.
# The asset is uploaded twice (GitHub can't alias an asset across
# releases); that's the price of keeping both a fixed URL and a history.
- name: Publish releases
shell: bash
run: |
set -euo pipefail
ver="${{ steps.version.outputs.version }}"

cat > notes.md <<EOF
Self-contained linux-x64 server bundle built from \`${GITHUB_SHA}\`.

Version: \`${ver}\`

Contains \`dist/\` (incl. the \`dist/client\` web UI) + production \`node_modules/\`
with native deps, so a node needs only Node 22.16+ — no install step.

**Nodes track the rolling \`nightly\` release**, so a \`systemctl --user restart
t3code.service\` always pulls the newest build:

\`\`\`bash
curl -fsSL https://github.com/${GITHUB_REPOSITORY}/releases/download/nightly/t3-server.tgz \\
| tar xz -C ~/.local/share/t3-nightly
\`\`\`

**To pin or roll back** a node to this exact build, point its \`ExecStartPre\` at
this immutable tag instead:

\`\`\`
https://github.com/${GITHUB_REPOSITORY}/releases/download/v${ver}/t3-server.tgz
\`\`\`
EOF

# 1. Immutable versioned release (rollback target).
gh release create "v${ver}" t3-server.tgz \
--title "Fork nightly ${ver}" \
--notes-file notes.md \
--prerelease \
--target "${GITHUB_SHA}"

# 2. Rolling pointer. Recreated so the tag tracks the built commit.
gh release delete nightly --yes --cleanup-tag 2>/dev/null || true
gh release create nightly t3-server.tgz \
--title "Fork nightly ${ver} (rolling)" \
--notes-file notes.md \
--prerelease \
--target "${GITHUB_SHA}"
21 changes: 17 additions & 4 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,21 @@ function resolveAppVariant(value: string | undefined): AppVariant {
}

const variant = VARIANT_CONFIG[APP_VARIANT];
// Upstream's personal-team mode (reduced capabilities) takes precedence.
// `T3CODE_IOS_BUNDLE_ID` is the fork's escape hatch for building on a *paid*
// team that isn't T3 Tools: the widgets app group is derived from the bundle ID
// (`group.<bundle id>`) and app group identifiers are globally unique across
// Apple teams, so the bundle ID must change with the team — but unlike a
// personal team, a paid team can still sign widgets/sharing/Sign in with Apple.
const iosBundleIdentifier = isIosPersonalTeamBuild
? personalTeamBundleIdentifier!
: variant.iosBundleIdentifier;
: (repoEnv.T3CODE_IOS_BUNDLE_ID ?? variant.iosBundleIdentifier);

// Fork identity, deliberately user-visible only: the internal `@t3tools/*`
// package names stay upstream's, which is what keeps upstream merges cheap
// (renaming them would touch ~1000 files and conflict on every sync). Set
// T3CODE_APP_NAME to override, or to `variant.appName` for an unbranded build.
const appName = repoEnv.T3CODE_APP_NAME ?? `${variant.appName} (JetBlk)`;

const dmSansFonts = {
regular: "@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf",
Expand Down Expand Up @@ -157,7 +169,7 @@ const sharingPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
// family names without waiting for runtime font loading.

const config: ExpoConfig = {
name: variant.appName,
name: appName,
slug: "t3-code",
platforms: ["ios", "android"],
scheme: variant.scheme,
Expand All @@ -184,8 +196,9 @@ const config: ExpoConfig = {
bundleIdentifier: iosBundleIdentifier,
// Pin code signing to the T3 Tools team so non-interactive `expo run:ios`
// does not fall back to a personal team (which cannot sign app groups,
// Sign in with Apple, or push notification entitlements).
appleTeamId: "ARK85ZXQ4Z",
// Sign in with Apple, or push notification entitlements). Contributors
// without T3 Tools membership can override with their own (paid) team.
appleTeamId: repoEnv.T3CODE_APPLE_TEAM_ID ?? "ARK85ZXQ4Z",
associatedDomains: [
`applinks:${variant.relyingParty}`,
`webcredentials:${variant.relyingParty}`,
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
--color-secondary-foreground: #262626;
--color-secondary-border: rgba(0, 0, 0, 0.08);
--color-switch-active: #34c759;
--color-warning: #f59e0b;

/* Danger */
--color-danger: #fef2f2;
Expand Down Expand Up @@ -136,6 +137,7 @@
--color-secondary-foreground: #f5f5f5;
--color-secondary-border: rgba(255, 255, 255, 0.06);
--color-switch-active: #30d158;
--color-warning: #fbbf24;

/* Danger */
--color-danger: rgba(239, 68, 68, 0.14);
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
import { SettingsProviderUsageRouteScreen } from "./features/settings/SettingsProviderUsageRouteScreen";
import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
Expand Down Expand Up @@ -177,6 +178,13 @@ const SettingsSheetStack = createNativeStackNavigator({
title: "Client Storage",
},
}),
SettingsProviderUsage: createNativeStackScreen({
screen: SettingsProviderUsageRouteScreen,
linking: "provider-usage",
options: {
title: "Usage & Limits",
},
}),
SettingsAuth: createNativeStackScreen({
screen: SettingsAuthRouteScreen,
linking: "auth",
Expand Down
Loading
Loading