Skip to content
2 changes: 2 additions & 0 deletions apps/server/src/binCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { projectCommand } from "./cli/project.ts";
import { threadCommand } from "./cli/thread.ts";
import { runDefaultServerCommand, serveCommand, startCommand } from "./cli/server.ts";
import { updateCommand } from "./cli/update.ts";
import { usageCommand } from "./cli/usage.ts";
import { uninstallCommand } from "./cli/uninstall.ts";
import { serviceLauncherCommand } from "./cli/serviceLauncher.ts";
import { claudeHistoryCommand } from "./cli/claudeHistory.ts";
Expand Down Expand Up @@ -78,6 +79,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
browserCommand,
projectCommand,
threadCommand,
usageCommand,
serviceCommand,
updateCommand,
uninstallCommand,
Expand Down
14 changes: 9 additions & 5 deletions apps/server/src/cli/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ const isThreadCliError = Schema.is(ThreadCliError);
* These errors are expected outcomes for a script, so they print as one line
* on stderr instead of the runtime's logged stack trace.
*/
const reportThreadErrors = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
export const reportThreadErrors = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.tapError((error) =>
isThreadCliError(error) ? Console.error(error.message) : Effect.void,
Expand All @@ -138,7 +138,7 @@ const reportThreadErrors = <A, E, R>(effect: Effect.Effect<A, E, R>) =>

const decodeFailureText = Schema.decodeUnknownOption(Schema.fromJsonString(OrchestratorMcpFailure));
const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
const printJson = (value: unknown) => Console.log(encodeJson(value));
export const printJson = (value: unknown) => Console.log(encodeJson(value));

const ToolResult = Schema.Struct({
isError: Schema.optional(Schema.Boolean),
Expand Down Expand Up @@ -202,9 +202,13 @@ export type ThreadTools = ReturnType<typeof makeThreadTools>;
* Signs in as a short-lived MCP client of the running server, the same way an
* outside agent does, and revokes that session when the command ends.
*/
const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* <A, E, R>(
export const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* <A, E, R>(
flags: { readonly baseDir: Option.Option<string> },
run: (tools: ThreadTools) => Effect.Effect<A, E, R>,
client: { readonly label: string; readonly name: string } = {
label: "t3 thread cli",
name: "t3-thread-cli",
},
) {
const logLevel = yield* GlobalFlag.LogLevel;
const config = yield* resolveCliAuthConfig(flags, logLevel);
Expand All @@ -217,7 +221,7 @@ const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* <A, E,
const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
const issued = yield* Effect.acquireRelease(
environmentAuth.issueMcpClientSession({
label: "t3 thread cli",
label: client.label,
// The CLI runs as the machine's owner. New threads still start at
// the project's default mode, never at this ceiling by default.
access: "full-access",
Expand All @@ -229,7 +233,7 @@ const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* <A, E,
const session = yield* mcpToolSession({
endpoint: new URL("/mcp", origin).href,
authorization: `Bearer ${issued.token}`,
clientName: "t3-thread-cli",
clientName: client.name,
}).pipe(Effect.mapError((cause) => new ThreadServerUnreachableError({ origin, cause })));
return yield* run(makeThreadTools(session, origin));
}).pipe(
Expand Down
181 changes: 181 additions & 0 deletions apps/server/src/cli/usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import {
OrchestratorMcpUsageLimitsResult,
OrchestratorMcpUsageSpendResult,
type OrchestratorMcpUsageWindow,
} from "@t3tools/contracts";
import { formatCount, formatPercent, formatTokens, formatUsd } from "@t3tools/shared/usageFormat";
import { formatDuration } from "@t3tools/shared/usageLimits";
import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
import { Command, Flag } from "effect/cli";

import { projectLocationFlags } from "./config.ts";
import { printJson, reportThreadErrors, withThreadTools } from "./thread.ts";

type Reading = Pick<
OrchestratorMcpUsageLimitsResult["providers"][number],
"ageMins" | "windows" | "unavailable"
>;

const MINUTE = 60_000;

const formatWindow = (window: OrchestratorMcpUsageWindow) => {
const reset =
window.resetsInMins === null
? ""
: window.resetsInMins === 0
? " · resets now"
: ` · resets in ${formatDuration(window.resetsInMins * MINUTE)}`;
return ` ${window.label.padEnd(16)} ${String(window.remainingPercent).padStart(3)}% left${reset}`;
};

const formatReading = (title: string, reading: Reading) => [
reading.ageMins === null
? title
: `${title} · read ${formatDuration(reading.ageMins * MINUTE)} ago`,
...(reading.unavailable === null
? reading.windows.map(formatWindow)
: [` ${reading.unavailable}`]),
];

const formatNotices = (notices: ReadonlyArray<string>) =>
notices.length === 0 ? [] : ["Notices", ...notices.map((notice) => ` ${notice}`)];

const formatLimits = (limits: OrchestratorMcpUsageLimitsResult) => {
const lines = [
...limits.providers.flatMap((provider) =>
formatReading(
String(provider.providerInstanceId) === String(provider.driverKind)
? provider.providerInstanceId
: `${provider.providerInstanceId} [${provider.driverKind}]`,
provider,
),
),
...limits.pooledAccounts.flatMap((account) =>
formatReading(`${account.source} · ${account.accountId} [${account.driverKind}]`, account),
),
];
return [
...(lines.length === 0 ? ["No providers are enabled."] : lines),
...formatNotices(limits.notices),
].join("\n");
};

const amount = (costUsd: number | null, totalTokens: number) =>
`${(costUsd === null ? "unpriced" : formatUsd(costUsd)).padStart(11)} ${formatTokens(totalTokens).padStart(6)} tokens`;

const formatSpend = (spend: OrchestratorMcpUsageSpendResult) => {
const { window } = spend;
const range =
window.days === 1
? "past 24 hours"
: `${window.days} days, ${window.sinceDay} to ${window.untilDay}`;
const width = Math.max(
0,
...[
...spend.providers.map((provider) => provider.provider),
...spend.models.map((model) => model.model),
...spend.periods.map((period) => period.start),
].map((label) => label.length),
);
return [
`Spend · ${range} (${window.timeZone}) · API estimate`,
` ${formatUsd(spend.costUsd)} · ${formatTokens(spend.totalTokens)} tokens · ${formatCount(spend.sessions)} sessions${
spend.unpricedShare > 0 ? ` · ${formatPercent(spend.unpricedShare)} unpriced` : ""
}`,
" By provider",
...spend.providers.map(
(provider) =>
` ${provider.provider.padEnd(width)} ${amount(provider.costUsd, provider.totalTokens)} ${formatCount(provider.sessions)} sessions`,
),
" By model",
...spend.models.map(
(model) => ` ${model.model.padEnd(width)} ${amount(model.costUsd, model.totalTokens)}`,
),
window.days === 1 ? " By hour" : " By day",
...spend.periods.map(
(period) => ` ${period.start.padEnd(width)} ${formatUsd(period.costUsd).padStart(11)}`,
),
...formatNotices(spend.notices),
].join("\n");
};

const jsonFlag = Flag.Boolean("json").pipe(
Flag.withDescription("Print JSON instead of text."),
Flag.withDefault(false),
);

const usageClient = { label: "t3 usage cli", name: "t3-usage-cli" };

const usageLimitsCommand = Command.make("limits", {
...projectLocationFlags,
refresh: Flag.Boolean("refresh").pipe(
Flag.withDescription("Re-read every provider before printing. Slower."),
Flag.withDefault(false),
),
json: jsonFlag,
}).pipe(
Command.withDescription(
"Show each provider's remaining subscription limits and when they reset.",
),
Command.withHandler((flags) =>
withThreadTools(
flags,
Effect.fn("cli.usage.limits")(function* (tools) {
const limits = yield* tools.call(
"t3_usage_limits",
{ refresh: flags.refresh },
OrchestratorMcpUsageLimitsResult,
);
if (flags.json) {
return yield* printJson(limits);
}
yield* Console.log(formatLimits(limits));
}),
usageClient,
).pipe(reportThreadErrors),
),
);

const usageSpendCommand = Command.make("spend", {
...projectLocationFlags,
days: Flag.ChoiceWithValue("days", [
["1", 1],
["7", 7],
["30", 30],
["90", 90],
] as const).pipe(
Flag.withDescription("Window in days, as on the Usage tab. 1 shows the past 24 hours."),
Flag.withDefault(30),
),
refresh: Flag.Boolean("refresh").pipe(
Flag.withDescription("Wait for sources that are still loading. Slower."),
Flag.withDefault(false),
),
json: jsonFlag,
}).pipe(
Command.withDescription(
"Show the Usage tab's token and dollar spend for this environment, with days in the server's time zone.",
),
Command.withHandler((flags) =>
withThreadTools(
flags,
Effect.fn("cli.usage.spend")(function* (tools) {
const spend = yield* tools.call(
"t3_usage_spend",
{ days: flags.days, refresh: flags.refresh },
OrchestratorMcpUsageSpendResult,
);
yield* flags.json ? printJson(spend) : Console.log(formatSpend(spend));
}),
usageClient,
).pipe(reportThreadErrors),
),
);

export const usageCommand = Command.make("usage").pipe(
Command.withDescription(
"Show what the Usage tab shows, from the running server: limits or spend.",
),
Command.withSubcommands([usageLimitsCommand, usageSpendCommand]),
);
7 changes: 6 additions & 1 deletion apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
import * as WorktreeHandlers from "./toolkits/worktree/handlers.ts";
import { WorktreeToolkit } from "./toolkits/worktree/tools.ts";
import * as WorktreeMcpService from "./WorktreeMcpService.ts";
import * as UsageReport from "../usage/UsageReport.ts";
import * as PullRequestsHandlers from "./toolkits/pullRequests/handlers.ts";
import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";
import * as DeviceHandlers from "./toolkits/device/handlers.ts";
Expand Down Expand Up @@ -1011,7 +1012,11 @@ export const layerPreviewToolkit = Layer.mergeAll(
export const layerOrchestratorToolkit = toolkitRegistration(
OrchestratorToolkit,
OrchestratorHandlers.layer,
).pipe(Layer.provide(OrchestratorMcpService.layer), Layer.provide(ThreadMetadataMcpService.layer));
).pipe(
Layer.provide(OrchestratorMcpService.layer),
Layer.provide(ThreadMetadataMcpService.layer),
Layer.provide(UsageReport.layer),
);

export const layerThreadToolkit = toolkitRegistration(ThreadToolkit, ThreadHandlers.layer);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,18 @@ import * as ProjectService from "../project/ProjectService.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
import * as SecretRequests from "../secrets/SecretRequests.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as UsageLimitSources from "../usage/UsageLimitSources.ts";
import * as UsageService from "../usage/UsageService.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as McpHttpServer from "./McpHttpServer.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";

const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe(
Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
Layer.provide(Layer.mock(UsageLimitSources.UsageLimitSources)({})),
Layer.provide(Layer.mock(UsageService.UsageService)({})),
Layer.provide(Layer.mock(ServerEnvironment.ServerEnvironment)({})),
);

// Effect returns a declared tool failure as `isError` with its encoded payload
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,16 @@ import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts";
import { dispatchFailure } from "../threadAccess.ts";
import * as EnvironmentHandlers from "./environment/handlers.ts";
import * as AttachmentHandlers from "./attachment/handlers.ts";
import * as UsageLimitSources from "../../usage/UsageLimitSources.ts";
import * as UsageService from "../../usage/UsageService.ts";
import * as ServerEnvironment from "../../environment/ServerEnvironment.ts";

const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe(
Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
Layer.provide(Layer.mock(UsageLimitSources.UsageLimitSources)({})),
Layer.provide(Layer.mock(UsageService.UsageService)({})),
Layer.provide(Layer.mock(ServerEnvironment.ServerEnvironment)({})),
);

// Effect returns a declared tool failure as `isError` with its encoded payload
Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/mcp/toolkits/orchestrator/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as McpToolAccess from "../../McpToolAccess.ts";
import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts";
import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts";
import * as UsageReport from "../../../usage/UsageReport.ts";

const handlers = {
orchestrator_capabilities: McpToolAccess.reads((input) =>
Expand All @@ -16,6 +17,28 @@ const handlers = {
return yield* service.capabilities(scope, input);
}),
),
t3_usage_limits: McpToolAccess.reads((input) =>
Effect.gen(function* () {
const report = yield* UsageReport.UsageReport;
return yield* report.readLimits({ refresh: input.refresh === true });
}),
),
t3_usage_spend: McpToolAccess.reads((input) =>
Effect.gen(function* () {
const report = yield* UsageReport.UsageReport;
return yield* report
.readSpend({
days: input.days ?? 30,
refresh: input.refresh === true,
})
.pipe(
Effect.mapError(
(error) =>
new OrchestratorMcpFailure({ code: "orchestration_error", message: error.detail }),
),
);
}),
),
delegate_task: McpToolAccess.actsAsCaller((input) =>
Effect.gen(function* () {
const scope = yield* McpInvocationContext.McpInvocationContext;
Expand Down
Loading
Loading